@somewhere-tech/cli 0.30.2 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ After `somewhere init`, Claude Code and Codex auto-connect via the `.mcp.json` i
46
46
  | `somewhere project view [name]` | Show project details |
47
47
  | `somewhere project delete <name>` | Delete with email confirmation |
48
48
  | `somewhere dev` | Serve your app on localhost, compiled by the platform's own compiler; save a file and the page updates in milliseconds |
49
- | `somewhere dev --cloud` | Deploy every save to a shareable private preview URL instead of serving locally |
49
+ | `somewhere preview` | Run your app on the platform instead of your machine — every save goes to a private URL, and production is untouched until you promote |
50
50
  | `somewhere dev <cmd...>` | Run your own command locally with the project's env vars injected |
51
51
  | `somewhere deploy` | Deploy current directory to linked project |
52
52
  | `somewhere deploy --dry-run` | Preview the deploy diff without shipping |
@@ -234,24 +234,40 @@ Environment variables come from a `.env` in the project directory. Run
234
234
  on the platform), then fill in the ones you want locally. `somewhere dev`
235
235
  names any key the project expects that has no local value.
236
236
 
237
- ### `--cloud`
237
+ ## `somewhere preview`
238
238
 
239
- `somewhere dev --cloud` deploys every save to a shareable private preview URL
240
- instead of serving locally, and prints the preview capability URL and the
241
- `somewhere promote` command after each update. Use it when you want a URL to
242
- send someone, or when the agent doing the work reaches the platform only over
243
- MCP and has no local machine to serve from. It needs the development
244
- environment, which is on the Pro and Scale plans and is enabled per account;
245
- without it the command returns `CLOUD_DEV_NOT_ENABLED`. Serving locally has no
246
- such requirement, on any plan.
239
+ There are two loops, and they are named after where the app runs.
240
+ `somewhere dev` runs it on your machine. `somewhere preview` runs it on the
241
+ platform.
247
242
 
248
- A private preview is a candidate built against your live version, so a project
249
- that has never been published has nothing for the first one to build on. On
250
- such a project `--cloud` publishes once it says so before it does — and every
243
+ `somewhere preview` sends every save to a private URL, reachable only by you
244
+ until you share the link. The build is the one production would get. The
245
+ database is a separate copy of your schema with none of your production rows,
246
+ so nothing you try in a preview can touch real data. After each update the
247
+ command prints that URL and the `somewhere promote` command that makes those
248
+ exact bytes live. Reach for it when you want a URL to send someone, or when the
249
+ agent doing the work reaches the platform over MCP and has no local machine to
250
+ serve from.
251
+
252
+ Nothing your users see changes while you preview. Production keeps serving what
253
+ you last promoted.
254
+
255
+ The URL is single-use and short-lived by design: opening it exchanges it for a
256
+ private session in that browser, and saving a file replaces it with a new one.
257
+ Use the newest URL the command printed. Anyone without a live URL — signed out,
258
+ or signed in as someone else — gets a 404.
259
+
260
+ A preview is built against your live version, so a project that has never been
261
+ published has nothing for the first one to build on. On such a project
262
+ `somewhere preview` publishes once — it says so before it does — and every
251
263
  preview after that stays private to you and never changes what is live.
252
264
 
253
- `somewhere dev --local` is accepted for compatibility and does what bare
254
- `somewhere dev` does.
265
+ `somewhere preview` is on the Pro and Scale plans. `somewhere dev` runs the same
266
+ app on your machine on every plan, and deploying is unaffected on every plan.
267
+
268
+ `somewhere dev --cloud` still starts the same loop and points you at the new
269
+ name. `somewhere dev --local` is accepted and does what bare `somewhere dev`
270
+ does.
255
271
 
256
272
  ### Running your own command
257
273
 
@@ -5,6 +5,7 @@ import { join, relative } from 'node:path';
5
5
  import { reportTypecheck } from './typecheck.js';
6
6
  import { runTypecheck } from '../lib/typecheck.js';
7
7
  import chokidar from 'chokidar';
8
+ import prompts from 'prompts';
8
9
  import open from '../lib/open.js';
9
10
  import ora from '../lib/spinner.js';
10
11
  import { ApiClient, CliApiError, LONG_CALL_TIMEOUT_MS } from '../lib/client.js';
@@ -26,6 +27,29 @@ import { isRecord, unwrapPlatformData } from '../lib/platform-command.js';
26
27
  const WATCH_EXTS = /\.(ts|tsx|js|jsx|mjs|html|css|json|svg|md|txt|png|jpe?g|gif|webp|ico|woff2?|ttf|otf)$/i;
27
28
  const DEBOUNCE_MS = 500;
28
29
  const RETRYABLE_DRAFT_CODES = new Set(['TIMEOUT', 'SERVER_SLOW', 'NETWORK_ERROR']);
30
+ /**
31
+ * The preview this loop was watching has finished — it was promoted, or closed.
32
+ *
33
+ * A promote ends the preview session, but the watcher used to keep running and
34
+ * keep failing, once per save, with the platform's API-client wording
35
+ * (tsk_74375b3c). The loop knows perfectly well what happened; it should stop
36
+ * and say so, not relay a refusal written for a machine.
37
+ */
38
+ class PreviewFinishedError extends Error {
39
+ reason;
40
+ constructor(reason) {
41
+ super(reason);
42
+ this.reason = reason;
43
+ this.name = 'PreviewFinishedError';
44
+ }
45
+ }
46
+ /** Did the platform just tell us this preview is over? */
47
+ function previewFinishedReason(err) {
48
+ if (!(err instanceof CliApiError) || err.code !== 'DRAFT_SESSION_TERMINAL')
49
+ return null;
50
+ const status = err.data?.terminal_status;
51
+ return status === 'promoted' ? 'promoted' : 'closed';
52
+ }
29
53
  export async function mintPreviewHandoff(client, projectId, draftId, candidateReleaseId) {
30
54
  const cap = await client.call('POST', `/projects/${encodeURIComponent(projectId)}/preview/mint`, { draft_id: draftId, candidate_release_id: candidateReleaseId });
31
55
  if (!cap || typeof cap.preview_url !== 'string' || !cap.preview_url.includes('/__sw_cap?t=')) {
@@ -65,6 +89,22 @@ export async function callDraftCandidate(client, path, body) {
65
89
  });
66
90
  }
67
91
  }
92
+ export function registerPreview(program) {
93
+ program
94
+ .command('preview')
95
+ .description('Run your app on the platform instead of your machine. Every save goes to a private URL, '
96
+ + 'reachable only by you until you share the link. The build is the one production would get; '
97
+ + 'the database is a separate copy of your schema, so nothing you try here can touch production '
98
+ + 'rows. Nothing your users see changes — production keeps serving what you last promoted, until '
99
+ + 'you run `somewhere promote`. Reach for this when you want the real hosted app in front of you, '
100
+ + 'or when your agent reaches the platform over MCP and cannot serve on localhost. '
101
+ + 'Available on the Pro and Scale plans; `somewhere dev` runs the app on your machine on every plan.')
102
+ .option('--project <id>', 'Override project ID')
103
+ .option('--publish-first', 'For a project that has never been published: publish this directory to production first, so the preview has a live version to build on. Without it you are asked, and a script that cannot be asked is refused.')
104
+ .action(async (opts) => {
105
+ await runHotDeploy(opts);
106
+ });
107
+ }
68
108
  export function registerDev(program) {
69
109
  program
70
110
  .command('dev [cmd...]')
@@ -75,10 +115,11 @@ export function registerDev(program) {
75
115
  'production app, and this is a faster window onto it. Same app, same build. ' +
76
116
  'Reaching the project DATABASE from the local loop is a plan feature and the command says so ' +
77
117
  'once at startup when your plan does not include it; deploying is unaffected on every plan. ' +
78
- '--cloud deploys every save to a shareable private preview URL instead of serving locally. ' +
118
+ 'To see the same app running on the platform instead of your machine, use `somewhere preview`. ' +
79
119
  'Pass a command (e.g. `somewhere dev npm run dev`) to run it locally with platform env vars.')
80
120
  .option('--project <id>', 'Override project ID')
81
- .option('--cloud', 'Deploy every save to a shareable private preview URL instead of serving locally — the loop for an agent that reaches the platform only over MCP')
121
+ .option('--cloud', 'Alias for `somewhere preview`')
122
+ .option('--publish-first', 'Only with `--cloud`: see `somewhere preview --help`')
82
123
  .option('--port <port>', 'Port to serve on (default 8787)')
83
124
  .option('--open', 'Open the app in your browser once it is serving')
84
125
  .option('--check', 'Typecheck (tsc --noEmit) before starting and EXIT on type errors instead of warning. Needs `npm install` in this directory — tsc reads package types out of node_modules, which the CLI\'s own dependency cache does not stand in for.')
@@ -90,6 +131,9 @@ export function registerDev(program) {
90
131
  return runLegacyExec(cmdParts);
91
132
  }
92
133
  if (opts.cloud) {
134
+ // Pre-launch alias. One line, then the identical loop — no ceremony,
135
+ // no grandfathering. `preview` is the name.
136
+ info('This is `somewhere preview`. Use that name — `--cloud` still works for now.');
93
137
  return runHotDeploy(opts);
94
138
  }
95
139
  return runLocalDev(opts);
@@ -369,16 +413,22 @@ export function localDevDbNotice(allowed, plans = []) {
369
413
  'Deploying is unaffected on every plan: `somewhere deploy` publishes to production and the deployed app reads and writes the database normally.',
370
414
  ];
371
415
  }
372
- export const CLOUD_DEV_UNAVAILABLE_MESSAGE = 'Private previews (`somewhere dev --cloud`) are available on the Pro and Scale plans. '
373
- + 'This account is on a plan that does not include them.';
416
+ export const CLOUD_DEV_UNAVAILABLE_MESSAGE = '`somewhere preview` is available on the Pro and Scale plans. '
417
+ + 'This account is on a plan that does not include it.';
374
418
  /**
375
419
  * Does this account have private previews?
376
420
  *
377
421
  * Returns `true`/`false` when the platform states it, and `null` when it does
378
422
  * not — a read that fails, or a platform that stopped reporting the field.
379
- * `null` must NEVER refuse: an unknown answer is not a denial, and blocking
380
- * someone whose account works today would be a worse bug than the one this
381
- * check exists to fix. Only an explicit `false` stops the command.
423
+ * `null` must NEVER refuse: an unknown answer is not a denial, and telling
424
+ * someone whose account works today to upgrade would be a worse bug than the
425
+ * one this check exists to fix. Only an explicit `false` stops the command.
426
+ *
427
+ * That is safe HERE, and only here, because of where the answer is used: an
428
+ * unknown entitlement is read on the path that already requires explicit
429
+ * consent to publish (see resolveBaseRelease), so it can no longer wave through
430
+ * a production release nobody asked for. It is the RELEASE read, not this one,
431
+ * that must refuse when it cannot answer.
382
432
  *
383
433
  * On a never-published project the platform answers this with the plain plan
384
434
  * entitlement (there is no release to bind a preview to yet), which is exactly
@@ -403,45 +453,142 @@ export class CloudDevUnavailableError extends Error {
403
453
  this.name = 'CloudDevUnavailableError';
404
454
  }
405
455
  }
406
- /** The project's current live release, or null when it has never published. */
407
- export async function readActiveReleaseId(projectId) {
456
+ /**
457
+ * Read the project's live version, distinguishing "nothing is live" from
458
+ * "could not tell". Every failure mode — a refusal, a server error, a dropped
459
+ * connection, a 200 whose shape this CLI does not recognise — is `unknown`.
460
+ *
461
+ * `call` is injected so a fixture can drive each of those answers.
462
+ */
463
+ export async function readBaseReleaseState(projectId, call = callPlatformTool) {
464
+ let status;
408
465
  try {
409
- const status = unwrapPlatformData(await callPlatformTool('deploy_status', { project_id: projectId }, { allTools: true }));
410
- if (isRecord(status) && typeof status.active_release_id === 'string') {
411
- return status.active_release_id;
412
- }
466
+ status = unwrapPlatformData(await call('deploy_status', { project_id: projectId }, { allTools: true }));
413
467
  }
414
- catch {
415
- // Non-fatal: the caller treats "unknown" as "no base" and the deploy validates.
468
+ catch (err) {
469
+ return { known: false, reason: err instanceof Error ? err.message : String(err) };
470
+ }
471
+ if (!isRecord(status)) {
472
+ return { known: false, reason: 'the platform did not describe this project.' };
473
+ }
474
+ if (typeof status.active_release_id === 'string') {
475
+ return { known: true, activeReleaseId: status.active_release_id };
476
+ }
477
+ // The ONLY answer that may lead to a publish: the platform states this
478
+ // project is not published. Note the deliberate asymmetry — a project that IS
479
+ // published but does not name a version behind it falls through to unknown
480
+ // below rather than being treated as never-published, because the cost of
481
+ // being wrong in that direction is publishing over a live app.
482
+ if (status.published === false)
483
+ return { known: true, activeReleaseId: null };
484
+ return {
485
+ known: false,
486
+ reason: 'the platform did not say which version of this project is live.',
487
+ };
488
+ }
489
+ /** The live version could not be read, so nothing may be published over it. */
490
+ export class BaseReleaseUnknownError extends Error {
491
+ reason;
492
+ code = 'BASE_RELEASE_UNKNOWN';
493
+ constructor(reason) {
494
+ super('Could not tell whether this project already has a live version, so nothing was published. '
495
+ + `The platform said: ${reason}`);
496
+ this.reason = reason;
497
+ this.name = 'BaseReleaseUnknownError';
416
498
  }
417
- return null;
418
499
  }
419
500
  /**
420
- * Guarantee there is a live version for a private preview to build on,
421
- * publishing once if there is none.
501
+ * Publishing this directory to production was never agreed to.
422
502
  *
423
- * THE ORDER IS THE CONTRACT (tsk_cf48f4ab). A private preview builds a
424
- * candidate against the project's live version, so a never-published project
425
- * has nothing to build on and the platform refuses. Publishing once here out
426
- * loud, never silently — is what makes `somewhere dev --cloud` work on a brand
427
- * new project. But private previews are also a plan feature, and the platform
428
- * enforces that on the preview request, which is the step AFTER this publish.
429
- * So the entitlement is read FIRST: an account without private previews is
430
- * refused having created nothing, instead of being handed a live production
431
- * version it never asked for and then told the command is unavailable.
503
+ * `declined` asked, and the answer was no.
504
+ * `not-asked` nothing here could ask (a script, an agent, a piped shell), so
505
+ * the answer is no by default. Consent that cannot be given is not consent.
506
+ */
507
+ export class PublishConsentRequiredError extends Error {
508
+ why;
509
+ code = 'PUBLISH_CONSENT_REQUIRED';
510
+ constructor(why) {
511
+ super('Nothing was published.');
512
+ this.why = why;
513
+ this.name = 'PublishConsentRequiredError';
514
+ }
515
+ }
516
+ /**
517
+ * Find the live version a private preview will build on — and, on a project
518
+ * that has none, publish one ONLY after the person running the command says so.
519
+ *
520
+ * THE ORDER IS THE CONTRACT (tsk_cf48f4ab, tsk_5504e045). Every step below is
521
+ * placed so that the customer's live site cannot change as a side effect of
522
+ * asking for a preview:
523
+ *
524
+ * 1. The plan entitlement is read FIRST, before any write and before any
525
+ * question. An account without private previews is refused having created
526
+ * nothing, rather than handed a production release and then told the
527
+ * command is unavailable.
528
+ * 2. The live version is read, and an unreadable answer STOPS the command.
529
+ * This is the reversal: `null` used to mean both "nothing is live" and
530
+ * "I could not tell", and the second one published over live projects.
531
+ * 3. A project that already has a live version returns it and publishes
532
+ * NOTHING. This is the overwhelmingly common path.
533
+ * 4. Only a positively-confirmed "never published" reaches the publish, and
534
+ * only after explicit consent — a confirmation, or `--publish-first`.
432
535
  *
433
- * `publish` is injected so a fixture can prove it was never called on the
434
- * refusal path the ordering is the behaviour under test, not the call shape.
536
+ * Why an unknown ENTITLEMENT does not refuse here, when an unknown RELEASE
537
+ * does: the entitlement question is only ever asked on the path that already
538
+ * requires the customer's explicit consent to publish, and consent settles it.
539
+ * A working account is never blocked by a read that failed; it is only ever
540
+ * asked. Only a stated `false` refuses, so nobody is told to upgrade on the
541
+ * strength of a read that did not answer.
542
+ *
543
+ * `publish` and `confirmPublish` are injected so a fixture can prove `publish`
544
+ * was never called on each refusal path — the ordering is the behaviour under
545
+ * test, not the call shape.
435
546
  */
436
- export async function ensureBaseRelease(args) {
437
- // Only an explicit `false` refuses; `null` means the platform did not say,
438
- // and an unknown answer must never block an account that works today.
547
+ export async function resolveBaseRelease(args) {
439
548
  if ((await args.cloudDevAllowed()) === false)
440
549
  throw new CloudDevUnavailableError();
550
+ const state = await args.readBaseReleaseState();
551
+ if (!state.known)
552
+ throw new BaseReleaseUnknownError(state.reason);
553
+ if (state.activeReleaseId)
554
+ return { baseReleaseId: state.activeReleaseId, published: false };
441
555
  args.announce?.('This project has never been published, so there is no live version for a private preview to build on.');
442
- args.announce?.('Publishing it once now to create the first version. After this, every preview stays private to you.');
556
+ args.announce?.('Publishing it once now would put the files in this directory in front of your users. '
557
+ + 'After that, every preview stays private to you and production only changes when you promote.');
558
+ const consent = await args.confirmPublish();
559
+ if (consent !== 'granted')
560
+ throw new PublishConsentRequiredError(consent);
443
561
  await args.publish();
444
- return args.readActiveReleaseId();
562
+ const after = await args.readBaseReleaseState();
563
+ if (!after.known || !after.activeReleaseId) {
564
+ throw new BaseReleaseUnknownError('the first version was published, but the live version could not be read back. '
565
+ + 'Run `somewhere preview` again.');
566
+ }
567
+ return { baseReleaseId: after.activeReleaseId, published: true };
568
+ }
569
+ /**
570
+ * Ask, once, before the one thing `somewhere preview` can do to a live site.
571
+ *
572
+ * A prompt is the primary mechanism rather than a bare flag because the publish
573
+ * happens on a FIRST run, when nobody knows a flag is needed — a flag-only
574
+ * design would either block every interactive first run or, worse, be added
575
+ * blindly and re-open the hole. A prompt nobody can answer is not consent
576
+ * either, so a non-interactive shell (a script, an agent, a piped terminal)
577
+ * gets `not-asked` and the refusal names `--publish-first`, which is the same
578
+ * consent given up front.
579
+ */
580
+ export async function readPublishConsent(publishFirst) {
581
+ if (publishFirst)
582
+ return 'granted';
583
+ if (!process.stdin.isTTY)
584
+ return 'not-asked';
585
+ const { ok } = await prompts({
586
+ type: 'confirm',
587
+ name: 'ok',
588
+ message: 'Publish this directory to production now, so the preview has a live version to build on?',
589
+ initial: false,
590
+ });
591
+ return ok === true ? 'granted' : 'declined';
445
592
  }
446
593
  async function runHotDeploy(opts) {
447
594
  const token = getToken();
@@ -459,60 +606,73 @@ async function runHotDeploy(opts) {
459
606
  subdomain = config.subdomain;
460
607
  }
461
608
  await showProjectNotices(client, projectId);
462
- // Initial full sync to the PREVIEW slot (preview: true). Writes only the
463
- // owner-gated dev slot — never prod, never a version bump or history entry.
464
- // /deploy/patch rejects projects with no prior deploy, so a full (preview)
465
- // deploy first establishes the sandbox AND returns the {slug}-dev URL.
466
- const spinner = ora('Syncing to preview...').start();
467
609
  const { files, binaryFiles, functions } = collectFiles(cwd);
468
610
  const draftId = `draft_${randomUUID()}`;
469
611
  const firstOperationId = `previewop_${randomUUID()}`;
470
- // The first preview snapshot must name the production release it was read from
471
- // (base_release_id) — the platform binds the draft to that exact production
472
- // release. Read it from deploy_status; a project that has never published to
473
- // production has no base and starts the draft from empty.
474
- let baseReleaseId = await readActiveReleaseId(projectId);
475
- // An exact preview builds a private CANDIDATE against the project's live
476
- // version. A project that has never been published has no live version, so
477
- // there is nothing for the first candidate to build on and the platform
478
- // refuses with PREVIEW_REQUIRES_BASE_RELEASE. Publish once here — announced,
479
- // never silently so `somewhere dev` works on a brand-new project. Every
480
- // preview after this stays private and never changes what is live.
481
- if (!baseReleaseId) {
482
- spinner.stop();
483
- try {
484
- baseReleaseId = await ensureBaseRelease({
485
- cloudDevAllowed: () => readCloudDevAllowed(projectId),
486
- announce: info,
487
- publish: async () => {
488
- await callDraftCandidate(client, '/deploy', {
489
- project_id: projectId,
490
- scope: 'all',
491
- files,
492
- binary_files: binaryFiles,
493
- functions,
494
- replace_functions: true,
495
- });
496
- },
497
- readActiveReleaseId: () => readActiveReleaseId(projectId),
498
- });
612
+ // The first preview snapshot must name the production release it was read
613
+ // from (base_release_id) — the platform binds the preview to that exact
614
+ // production release. Everything about how that id is obtained, including
615
+ // the refusal to invent one, lives in resolveBaseRelease.
616
+ let baseReleaseId;
617
+ try {
618
+ const resolved = await resolveBaseRelease({
619
+ cloudDevAllowed: () => readCloudDevAllowed(projectId),
620
+ readBaseReleaseState: () => readBaseReleaseState(projectId),
621
+ confirmPublish: () => readPublishConsent(opts.publishFirst === true),
622
+ announce: info,
623
+ publish: async () => {
624
+ await callDraftCandidate(client, '/deploy', {
625
+ project_id: projectId,
626
+ scope: 'all',
627
+ files,
628
+ binary_files: binaryFiles,
629
+ functions,
630
+ replace_functions: true,
631
+ });
632
+ },
633
+ });
634
+ baseReleaseId = resolved.baseReleaseId;
635
+ if (resolved.published)
499
636
  success('Published — this project now has a live version.');
637
+ }
638
+ catch (err) {
639
+ // Every branch here says the same thing in different words: your live site
640
+ // is exactly as you left it. Nothing below may claim anything about whether
641
+ // this project is published — that is precisely the read that failed.
642
+ if (err instanceof CloudDevUnavailableError) {
643
+ error(err.message);
644
+ info('Nothing was created or changed — whatever is live stays live.');
645
+ info('`somewhere deploy` publishes to production on any plan, and `somewhere dev` runs the same app on your machine.');
646
+ process.exit(1);
500
647
  }
501
- catch (err) {
502
- if (err instanceof CloudDevUnavailableError) {
503
- error(err.message);
504
- info('Nothing was created this project has not been published.');
505
- info('`somewhere deploy` publishes to production on any plan, and `somewhere dev` runs the same app locally.');
506
- process.exit(1);
648
+ if (err instanceof BaseReleaseUnknownError) {
649
+ error(err.message);
650
+ info('Nothing was created or changed — whatever is live stays live.');
651
+ info('Try `somewhere preview` again. To publish this directory to production deliberately, run `somewhere deploy`.');
652
+ process.exit(1);
653
+ }
654
+ if (err instanceof PublishConsentRequiredError) {
655
+ if (err.why === 'declined') {
656
+ warn('Nothing was published — whatever is live stays live.');
507
657
  }
508
- if (!(isBuildError(err) && renderBuildError(err, cwd))) {
509
- error(err instanceof Error ? err.message : String(err));
658
+ else {
659
+ error('This project has never been published, so a preview has no live version to build on.');
660
+ info('Re-run as `somewhere preview --publish-first` to publish this directory to production first, or run `somewhere deploy` yourself.');
661
+ info('Nothing was created or changed — whatever is live stays live.');
510
662
  }
511
- error('Could not publish the first version, so the private preview has nothing to build on.');
512
663
  process.exit(1);
513
664
  }
514
- spinner.start('Syncing to preview...');
665
+ if (!(isBuildError(err) && renderBuildError(err, cwd))) {
666
+ error(err instanceof Error ? err.message : String(err));
667
+ }
668
+ error('Could not publish the first version, so the private preview has nothing to build on.');
669
+ process.exit(1);
515
670
  }
671
+ // Initial full sync to the PREVIEW slot (preview: true). Writes only the
672
+ // owner-gated dev slot — never prod, never a version bump or history entry.
673
+ // /deploy/patch rejects projects with no prior deploy, so a full (preview)
674
+ // deploy first establishes the sandbox AND returns the {slug}-dev URL.
675
+ const spinner = ora('Syncing to preview...').start();
516
676
  let candidateReleaseId = null;
517
677
  let initialHandoff;
518
678
  let initialAutoOpenUrl;
@@ -603,6 +763,24 @@ async function runHotDeploy(opts) {
603
763
  if (nextCandidate)
604
764
  candidateReleaseId = nextCandidate;
605
765
  }
766
+ catch (err) {
767
+ if (!(err instanceof PreviewFinishedError))
768
+ throw err;
769
+ // One line, in the two words the product uses, naming the next command.
770
+ // Nothing about how a preview is built reaches this terminal.
771
+ console.log('');
772
+ if (err.reason === 'promoted') {
773
+ success('Promoted — this preview is now your live app, and the preview has finished.');
774
+ }
775
+ else {
776
+ info('This preview has finished.');
777
+ }
778
+ info(`Run ${teal('somewhere preview')} to keep previewing.`);
779
+ if (timer)
780
+ clearTimeout(timer);
781
+ await watcher.close().catch(() => { });
782
+ process.exit(0);
783
+ }
606
784
  finally {
607
785
  deploying = false;
608
786
  if (pendingChanged.size || pendingDeleted.size)
@@ -737,6 +915,11 @@ async function deployBatch(client, projectId, cwd, changed, deleted, draftId, ex
737
915
  info(dim('Your last working preview is still up. Fix and save again.'));
738
916
  return null;
739
917
  }
918
+ const finished = previewFinishedReason(err);
919
+ if (finished) {
920
+ // Not a failed save — the preview itself is over. The loop stops on it.
921
+ throw new PreviewFinishedError(finished);
922
+ }
740
923
  console.log(`${dim(stamp())} ${label} ${red('✗ failed')} ${dim(`(${secs}s)`)}`);
741
924
  error(err instanceof Error ? err.message : String(err));
742
925
  return null;