@mondaydotcomorg/z2h-cli 0.20.0 → 0.21.0-feat-arielle-fixed-preview-id-3537677

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.
@@ -4,7 +4,6 @@ export interface DeployOptions {
4
4
  remote?: RemoteType;
5
5
  repoUrl?: string;
6
6
  preview?: boolean;
7
- previewId?: string;
8
7
  open?: boolean;
9
8
  }
10
9
  export declare function deployCommand(opts?: DeployOptions): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,KAAK,UAAU,EAA2C,MAAM,4BAA4B,CAAC;AAUtG,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IAGjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAWD,wBAAsB,aAAa,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB3E"}
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAE,KAAK,UAAU,EAA2C,MAAM,4BAA4B,CAAC;AAYtG,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IAGjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAWD,wBAAsB,aAAa,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB3E"}
@@ -1,7 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
2
 
3
3
  const os = require('node:os');
4
- const crypto = require('node:crypto');
5
4
  const fs = require('fs-extra');
6
5
  const util_logger = require('../util/logger.js');
7
6
  const shadow_paths = require('../shadow/paths.js');
@@ -18,6 +17,8 @@ const util_s3_client = require('../util/s3/client.js');
18
17
  const util_machineIdentity = require('../util/machine-identity.js');
19
18
  const util_broker_app = require('../util/broker/app.js');
20
19
  const util_openBrowser = require('../util/open-browser.js');
20
+ const shadow_metadata = require('../shadow/metadata.js');
21
+ const shadow_prepare = require('../shadow/prepare.js');
21
22
  const commands_build = require('./build.js');
22
23
  const commands_tag = require('./tag.js');
23
24
 
@@ -63,7 +64,8 @@ async function buildAndStamp(opts) {
63
64
  };
64
65
  }
65
66
  async function deployPreview({ paths, consumerName, appName, token, opts }) {
66
- const previewId = opts.previewId ?? crypto.randomUUID().slice(0, 8);
67
+ const meta = await shadow_metadata.readMetadata(paths);
68
+ const previewId = meta?.previewId ?? shadow_prepare.generatePreviewId();
67
69
  const previewSlug = util_appName.buildPreviewSlug(consumerName, previewId);
68
70
  const previewAppName = util_appName.mfAppName(previewSlug);
69
71
  const keyPrefix = `${constants.S3_PREVIEWS_FOLDER}/${previewAppName}/MF`;
@@ -73,10 +75,12 @@ async function deployPreview({ paths, consumerName, appName, token, opts }) {
73
75
  const nextVersion = (await util_auth_brokerCredentialProvider.fetchDeployVersion(previewAppName)) + 1;
74
76
  util_logger.info('building');
75
77
  const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote: '' });
78
+ util_logger.info('getting sts credentials');
76
79
  const creds = await util_auth_brokerCredentialProvider.brokerCredentialProvider({ appName: previewAppName, previewId })();
77
80
  util_s3_client.setupS3Client(creds);
78
81
  util_logger.info(`uploading preview build → s3://${constants.BUCKET_WRITE_NAME}/${keyPrefix}/`);
79
82
  await util_s3_upload.uploadDir(paths.shadowBuild, keyPrefix);
83
+ util_logger.info('updating manifest');
80
84
  await util_broker_app.registerOrUpdateApp({ appName: previewAppName, token, nextVersion, entry, gitRemote: '' });
81
85
  const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
82
86
  util_logger.output(`[z2h-cli] preview ready ✅\n app: ${appName}\n previewAppName: ${previewAppName}\n open: ${previewUrl}`, {
@@ -151,8 +155,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }) {
151
155
  // Acquire the deploy lock explicitly. Done after the build
152
156
  // so the lock hold stays tiny — it wraps only push → upload → manifest.
153
157
  // A 409 here means someone else is mid-deploy;
158
+ util_logger.info('getting sts credentials');
154
159
  const creds = await util_auth_brokerCredentialProvider.brokerCredentialProvider({ appName })();
155
160
  util_s3_client.setupS3Client(creds);
161
+ util_logger.info('acquiring deploy lock');
156
162
  await acquireDeployLock(appName, token);
157
163
  // Push before any S3 write so a rejected push (someone deployed during our
158
164
  // build) stops the deploy before the bundle/manifest go live
@@ -162,9 +168,11 @@ async function deployLive({ paths, consumerName, appName, token, opts }) {
162
168
  }
163
169
  util_logger.info(`uploading build → s3://${constants.BUCKET_WRITE_NAME}/${keyPrefix}/`);
164
170
  await util_s3_upload.uploadDir(paths.shadowBuild, keyPrefix);
171
+ util_logger.info('updating manifest');
165
172
  await util_broker_app.registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote });
166
173
  }
167
174
  finally {
175
+ util_logger.info('releasing deploy lock');
168
176
  await releaseLockBestEffort(appName, token);
169
177
  }
170
178
  // Tags are a post-deploy concern: the S3 upload has already succeeded, so a
@@ -4,7 +4,6 @@ export interface DeployOptions {
4
4
  remote?: RemoteType;
5
5
  repoUrl?: string;
6
6
  preview?: boolean;
7
- previewId?: string;
8
7
  open?: boolean;
9
8
  }
10
9
  export declare function deployCommand(opts?: DeployOptions): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../../src/commands/deploy.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,KAAK,UAAU,EAA2C,MAAM,4BAA4B,CAAC;AAUtG,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IAGjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAWD,wBAAsB,aAAa,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB3E"}
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../../src/commands/deploy.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAE,KAAK,UAAU,EAA2C,MAAM,4BAA4B,CAAC;AAYtG,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IAGjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAWD,wBAAsB,aAAa,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB3E"}
@@ -1,5 +1,4 @@
1
1
  import os from 'node:os';
2
- import { randomUUID } from 'node:crypto';
3
2
  import { readJson } from 'fs-extra';
4
3
  import { info, output, getOutputMode, warn } from '../util/logger.mjs';
5
4
  import { resolveConsumerPaths } from '../shadow/paths.mjs';
@@ -16,6 +15,8 @@ import { setupS3Client } from '../util/s3/client.mjs';
16
15
  import { getMachineAppName } from '../util/machine-identity.mjs';
17
16
  import { registerOrUpdateApp } from '../util/broker/app.mjs';
18
17
  import { openInBrowser } from '../util/open-browser.mjs';
18
+ import { readMetadata } from '../shadow/metadata.mjs';
19
+ import { generatePreviewId } from '../shadow/prepare.mjs';
19
20
  import { buildCommand } from './build.mjs';
20
21
  import { promptForTags } from './tag.mjs';
21
22
 
@@ -57,7 +58,8 @@ async function buildAndStamp(opts) {
57
58
  };
58
59
  }
59
60
  async function deployPreview({ paths, consumerName, appName, token, opts }) {
60
- const previewId = opts.previewId ?? randomUUID().slice(0, 8);
61
+ const meta = await readMetadata(paths);
62
+ const previewId = meta?.previewId ?? generatePreviewId();
61
63
  const previewSlug = buildPreviewSlug(consumerName, previewId);
62
64
  const previewAppName = mfAppName(previewSlug);
63
65
  const keyPrefix = `${S3_PREVIEWS_FOLDER}/${previewAppName}/MF`;
@@ -67,10 +69,12 @@ async function deployPreview({ paths, consumerName, appName, token, opts }) {
67
69
  const nextVersion = (await fetchDeployVersion(previewAppName)) + 1;
68
70
  info('building');
69
71
  const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote: '' });
72
+ info('getting sts credentials');
70
73
  const creds = await brokerCredentialProvider({ appName: previewAppName, previewId })();
71
74
  setupS3Client(creds);
72
75
  info(`uploading preview build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
73
76
  await uploadDir(paths.shadowBuild, keyPrefix);
77
+ info('updating manifest');
74
78
  await registerOrUpdateApp({ appName: previewAppName, token, nextVersion, entry, gitRemote: '' });
75
79
  const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
76
80
  output(`[z2h-cli] preview ready ✅\n app: ${appName}\n previewAppName: ${previewAppName}\n open: ${previewUrl}`, {
@@ -145,8 +149,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }) {
145
149
  // Acquire the deploy lock explicitly. Done after the build
146
150
  // so the lock hold stays tiny — it wraps only push → upload → manifest.
147
151
  // A 409 here means someone else is mid-deploy;
152
+ info('getting sts credentials');
148
153
  const creds = await brokerCredentialProvider({ appName })();
149
154
  setupS3Client(creds);
155
+ info('acquiring deploy lock');
150
156
  await acquireDeployLock(appName, token);
151
157
  // Push before any S3 write so a rejected push (someone deployed during our
152
158
  // build) stops the deploy before the bundle/manifest go live
@@ -156,9 +162,11 @@ async function deployLive({ paths, consumerName, appName, token, opts }) {
156
162
  }
157
163
  info(`uploading build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
158
164
  await uploadDir(paths.shadowBuild, keyPrefix);
165
+ info('updating manifest');
159
166
  await registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote });
160
167
  }
161
168
  finally {
169
+ info('releasing deploy lock');
162
170
  await releaseLockBestEffort(appName, token);
163
171
  }
164
172
  // Tags are a post-deploy concern: the S3 upload has already succeeded, so a
@@ -93,17 +93,13 @@ async function main() {
93
93
  .command('deploy')
94
94
  .description('Build, upload to S3, and register the app with the Z2H backend')
95
95
  .option('--dry-run', 'print what would happen without uploading anything')
96
- .option('--preview [previewId]', 'build an ephemeral, shareable preview instead of deploying — no git required, no deploy lock, never touches the live version; pass an existing previewId to update it instead of minting a new one')
96
+ .option('--preview', 'build an ephemeral, shareable preview instead of deploying — no git required, no deploy lock, never touches the live version; reuses the same preview URL across runs (stable link)')
97
97
  .option('--open', 'open the resulting app/preview URL in the browser on success')
98
98
  .option('--remote <type>', 'First deploy only: source control remote to initialise — s3 (shared storage) or github (your repo). Error if the app already has a git remote.')
99
99
  .option('--repo-url <url>', 'First deploy only: GitHub repo URL — required when --remote=github. Error if the app already has a git remote.')
100
100
  .action(async (opts) => {
101
101
  try {
102
- await trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => deployCommand({
103
- ...opts,
104
- preview: Boolean(opts.preview),
105
- previewId: typeof opts.preview === 'string' ? opts.preview : undefined,
106
- }), {
102
+ await trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => deployCommand(opts), {
107
103
  app_name: getCwdAppName(),
108
104
  });
109
105
  }
@@ -1,3 +1,4 @@
1
1
  import type { PreparedShadow } from '../types';
2
2
  export declare function prepareShadow(consumerDir?: string): Promise<PreparedShadow>;
3
+ export declare function generatePreviewId(): string;
3
4
  //# sourceMappingURL=prepare.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"prepare.d.ts","sourceRoot":"","sources":["../../../src/shadow/prepare.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAA8B,cAAc,EAAkB,MAAM,UAAU,CAAC;AAE3F,wBAAsB,aAAa,CAAC,WAAW,GAAE,MAAsB,GAAG,OAAO,CAAC,cAAc,CAAC,CAgChG"}
1
+ {"version":3,"file":"prepare.d.ts","sourceRoot":"","sources":["../../../src/shadow/prepare.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAA8B,cAAc,EAAkB,MAAM,UAAU,CAAC;AAE3F,wBAAsB,aAAa,CAAC,WAAW,GAAE,MAAsB,GAAG,OAAO,CAAC,cAAc,CAAC,CAgChG;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C"}
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { pathExists, readJson, ensureDir } from 'fs-extra';
2
3
  import { info } from '../util/logger.mjs';
3
4
  import { cleanAppName } from '../util/app-name.mjs';
@@ -35,6 +36,9 @@ async function prepareShadow(consumerDir = process.cwd()) {
35
36
  await syncShadow(paths, consumerPkg);
36
37
  return { appName, port, paths };
37
38
  }
39
+ function generatePreviewId() {
40
+ return randomUUID().slice(0, 8);
41
+ }
38
42
  /**
39
43
  * Resolve the dev server port. Prefer the persisted port if it's still free
40
44
  * (URL stays stable across restarts). Otherwise pick a fresh one. The
@@ -43,16 +47,20 @@ async function prepareShadow(consumerDir = process.cwd()) {
43
47
  */
44
48
  async function resolvePortAndMetadata(paths, appName) {
45
49
  const existing = await readMetadata(paths);
46
- if (existing?.port && (await isPortFree(existing.port))) {
47
- return existing;
50
+ let port = existing?.port;
51
+ if (!port || !(await isPortFree(port))) {
52
+ port = await pickPort();
48
53
  }
49
54
  const next = {
50
55
  createdAt: existing?.createdAt ?? new Date().toISOString(),
51
56
  appName,
52
- port: await pickPort(),
57
+ port,
58
+ previewId: existing?.previewId ?? generatePreviewId(),
53
59
  };
54
- await writeMetadata(paths, next);
60
+ if (JSON.stringify(next) !== JSON.stringify(existing)) {
61
+ await writeMetadata(paths, next);
62
+ }
55
63
  return next;
56
64
  }
57
65
 
58
- export { prepareShadow };
66
+ export { generatePreviewId, prepareShadow };
@@ -26,6 +26,7 @@ export interface ShadowMetadata {
26
26
  createdAt: string;
27
27
  appName: string;
28
28
  port: number;
29
+ previewId: string;
29
30
  }
30
31
  export interface ConsumerPaths {
31
32
  consumerDir: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAOD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,IAAI,CAAC;IACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAGD,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB;AAGD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,aAAa,CAAC;CACtB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAOD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,IAAI,CAAC;IACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAGD,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB;AAGD,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB;AAGD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,aAAa,CAAC;CACtB"}
package/dist/index.js CHANGED
@@ -99,17 +99,13 @@ async function main() {
99
99
  .command('deploy')
100
100
  .description('Build, upload to S3, and register the app with the Z2H backend')
101
101
  .option('--dry-run', 'print what would happen without uploading anything')
102
- .option('--preview [previewId]', 'build an ephemeral, shareable preview instead of deploying — no git required, no deploy lock, never touches the live version; pass an existing previewId to update it instead of minting a new one')
102
+ .option('--preview', 'build an ephemeral, shareable preview instead of deploying — no git required, no deploy lock, never touches the live version; reuses the same preview URL across runs (stable link)')
103
103
  .option('--open', 'open the resulting app/preview URL in the browser on success')
104
104
  .option('--remote <type>', 'First deploy only: source control remote to initialise — s3 (shared storage) or github (your repo). Error if the app already has a git remote.')
105
105
  .option('--repo-url <url>', 'First deploy only: GitHub repo URL — required when --remote=github. Error if the app already has a git remote.')
106
106
  .action(async (opts) => {
107
107
  try {
108
- await util_tracker.trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => commands_deploy.deployCommand({
109
- ...opts,
110
- preview: Boolean(opts.preview),
111
- previewId: typeof opts.preview === 'string' ? opts.preview : undefined,
112
- }), {
108
+ await util_tracker.trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => commands_deploy.deployCommand(opts), {
113
109
  app_name: util_appName.getCwdAppName(),
114
110
  });
115
111
  }
@@ -1,3 +1,4 @@
1
1
  import type { PreparedShadow } from '../types';
2
2
  export declare function prepareShadow(consumerDir?: string): Promise<PreparedShadow>;
3
+ export declare function generatePreviewId(): string;
3
4
  //# sourceMappingURL=prepare.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"prepare.d.ts","sourceRoot":"","sources":["../../src/shadow/prepare.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAA8B,cAAc,EAAkB,MAAM,UAAU,CAAC;AAE3F,wBAAsB,aAAa,CAAC,WAAW,GAAE,MAAsB,GAAG,OAAO,CAAC,cAAc,CAAC,CAgChG"}
1
+ {"version":3,"file":"prepare.d.ts","sourceRoot":"","sources":["../../src/shadow/prepare.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAA8B,cAAc,EAAkB,MAAM,UAAU,CAAC;AAE3F,wBAAsB,aAAa,CAAC,WAAW,GAAE,MAAsB,GAAG,OAAO,CAAC,cAAc,CAAC,CAgChG;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C"}
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
2
 
3
+ const crypto = require('node:crypto');
3
4
  const fs = require('fs-extra');
4
5
  const util_logger = require('../util/logger.js');
5
6
  const util_appName = require('../util/app-name.js');
@@ -37,6 +38,9 @@ async function prepareShadow(consumerDir = process.cwd()) {
37
38
  await shadow_sync.syncShadow(paths, consumerPkg);
38
39
  return { appName, port, paths };
39
40
  }
41
+ function generatePreviewId() {
42
+ return crypto.randomUUID().slice(0, 8);
43
+ }
40
44
  /**
41
45
  * Resolve the dev server port. Prefer the persisted port if it's still free
42
46
  * (URL stays stable across restarts). Otherwise pick a fresh one. The
@@ -45,16 +49,21 @@ async function prepareShadow(consumerDir = process.cwd()) {
45
49
  */
46
50
  async function resolvePortAndMetadata(paths, appName) {
47
51
  const existing = await shadow_metadata.readMetadata(paths);
48
- if (existing?.port && (await shadow_port.isPortFree(existing.port))) {
49
- return existing;
52
+ let port = existing?.port;
53
+ if (!port || !(await shadow_port.isPortFree(port))) {
54
+ port = await shadow_port.pickPort();
50
55
  }
51
56
  const next = {
52
57
  createdAt: existing?.createdAt ?? new Date().toISOString(),
53
58
  appName,
54
- port: await shadow_port.pickPort(),
59
+ port,
60
+ previewId: existing?.previewId ?? generatePreviewId(),
55
61
  };
56
- await shadow_metadata.writeMetadata(paths, next);
62
+ if (JSON.stringify(next) !== JSON.stringify(existing)) {
63
+ await shadow_metadata.writeMetadata(paths, next);
64
+ }
57
65
  return next;
58
66
  }
59
67
 
68
+ exports.generatePreviewId = generatePreviewId;
60
69
  exports.prepareShadow = prepareShadow;
package/dist/types.d.ts CHANGED
@@ -26,6 +26,7 @@ export interface ShadowMetadata {
26
26
  createdAt: string;
27
27
  appName: string;
28
28
  port: number;
29
+ previewId: string;
29
30
  }
30
31
  export interface ConsumerPaths {
31
32
  consumerDir: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAOD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,IAAI,CAAC;IACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAGD,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB;AAGD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,aAAa,CAAC;CACtB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAOD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,IAAI,CAAC;IACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAGD,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB;AAGD,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB;AAGD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,aAAa,CAAC;CACtB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.0-feat-arielle-fixed-preview-id-3537677",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -57,6 +57,8 @@ const ASSET_MANIFEST = {
57
57
  entrypoints: ['main.js'],
58
58
  };
59
59
 
60
+ const STUB_PREVIEW_ID = 'existing-preview-id' as const;
61
+
60
62
  describe('deploy --preview', () => {
61
63
  let tmp: string;
62
64
  let cwd: string;
@@ -99,6 +101,14 @@ describe('deploy --preview', () => {
99
101
  await rm(tmp, { recursive: true, force: true });
100
102
  });
101
103
 
104
+ const writeMetadata = (previewId: string) =>
105
+ writeJson(path.join(tmp, '.zth', 'metadata.json'), {
106
+ createdAt: '2026-01-01T00:00:00.000Z',
107
+ appName: 'my-app',
108
+ port: 13000,
109
+ previewId,
110
+ });
111
+
102
112
  it('throws when the consumer package.json has no name', async () => {
103
113
  await writeJson(path.join(tmp, 'package.json'), {});
104
114
  await expect(deployCommand({ preview: true })).rejects.toThrow(/must have a "name" field/);
@@ -118,49 +128,48 @@ describe('deploy --preview', () => {
118
128
  expect(uploadDir).toHaveBeenCalledWith(path.join(tmp, '.zth', 'build'), `previews/${previewAppName}/MF`);
119
129
  });
120
130
 
121
- it('calls registerOrUpdateApp for a brand-new preview at version 1 with an empty gitRemote', async () => {
122
- vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue(0);
131
+ it('reuses the sticky previewId from metadata', async () => {
132
+ await writeMetadata(STUB_PREVIEW_ID);
133
+ vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue(2);
123
134
 
124
- await deployCommand({ preview: true, previewId: 'existing-preview-id' });
135
+ await deployCommand({ preview: true });
125
136
 
126
- const previewSlug = buildPreviewSlug('my-app', 'existing-preview-id');
127
- const previewAppName = `mf-${previewSlug}`;
137
+ const call = vi.mocked(brokerProvider.brokerCredentialProvider).mock.calls[0][0];
138
+ expect(call.previewId).toBe(STUB_PREVIEW_ID);
139
+
140
+ const previewAppName = `mf-${buildPreviewSlug('my-app', STUB_PREVIEW_ID)}`;
141
+ expect(uploadDir).toHaveBeenCalledWith(path.join(tmp, '.zth', 'build'), `previews/${previewAppName}/MF`);
128
142
 
129
143
  expect(brokerApp.registerOrUpdateApp).toHaveBeenCalledWith({
130
144
  appName: previewAppName,
131
145
  token: 'jwt-stub',
132
- nextVersion: 1,
133
- entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 1, gitRemote: '' }),
146
+ nextVersion: 3,
147
+ entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 3, gitRemote: '' }),
134
148
  gitRemote: '',
135
149
  });
136
150
  });
137
151
 
138
- it('reuses a supplied previewId and calls registerOrUpdateApp at version+1', async () => {
139
- vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue(2);
140
-
141
- await deployCommand({ preview: true, previewId: 'existing-preview-id' });
142
-
143
- const call = vi.mocked(brokerProvider.brokerCredentialProvider).mock.calls[0][0];
144
- expect(call.previewId).toBe('existing-preview-id');
145
-
146
- const previewSlug = buildPreviewSlug('my-app', 'existing-preview-id');
147
- const previewAppName = `mf-${previewSlug}`;
152
+ it('calls registerOrUpdateApp for a brand-new preview at version 1 with an empty gitRemote', async () => {
153
+ await writeMetadata(STUB_PREVIEW_ID);
154
+ vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue(0);
148
155
 
149
- expect(uploadDir).toHaveBeenCalledWith(path.join(tmp, '.zth', 'build'), `previews/${previewAppName}/MF`);
156
+ await deployCommand({ preview: true });
150
157
 
158
+ const previewAppName = `mf-${buildPreviewSlug('my-app', STUB_PREVIEW_ID)}`;
151
159
  expect(brokerApp.registerOrUpdateApp).toHaveBeenCalledWith({
152
160
  appName: previewAppName,
153
161
  token: 'jwt-stub',
154
- nextVersion: 3,
155
- entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 3, gitRemote: '' }),
162
+ nextVersion: 1,
163
+ entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 1, gitRemote: '' }),
156
164
  gitRemote: '',
157
165
  });
158
166
  });
159
167
 
160
168
  it('passes a full CDN URL (not a relative path) as publicUrl to the build', async () => {
161
- await deployCommand({ preview: true, previewId: 'existing-preview-id' });
169
+ await writeMetadata(STUB_PREVIEW_ID);
170
+ await deployCommand({ preview: true });
162
171
 
163
- const previewAppName = `mf-${buildPreviewSlug('my-app', 'existing-preview-id')}`;
172
+ const previewAppName = `mf-${buildPreviewSlug('my-app', STUB_PREVIEW_ID)}`;
164
173
  expect(buildCommand).toHaveBeenCalledWith(
165
174
  expect.objectContaining({
166
175
  publicUrl: `https://bigbrain-zth-mf-assets.bigbrain.me/previews/${previewAppName}/MF`,
@@ -178,9 +187,10 @@ describe('deploy --preview', () => {
178
187
  });
179
188
 
180
189
  it('--open opens the previewUrl in the browser on success', async () => {
181
- await deployCommand({ preview: true, previewId: 'existing-preview-id', open: true });
190
+ await writeMetadata(STUB_PREVIEW_ID);
191
+ await deployCommand({ preview: true, open: true });
182
192
 
183
- const previewSlug = buildPreviewSlug('my-app', 'existing-preview-id');
193
+ const previewSlug = buildPreviewSlug('my-app', STUB_PREVIEW_ID);
184
194
  expect(openInBrowser).toHaveBeenCalledWith(`https://bigbrain.me/bigbrain-vibe/${previewSlug}`);
185
195
  });
186
196
 
@@ -195,19 +205,20 @@ describe('deploy --preview', () => {
195
205
  });
196
206
 
197
207
  it('--json emits a machine-readable payload with an app-shaped previewUrl (not ?z2h_preview=)', async () => {
208
+ await writeMetadata(STUB_PREVIEW_ID);
198
209
  const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
199
210
  setOutputMode('json');
200
211
 
201
- await deployCommand({ preview: true, previewId: 'existing-preview-id' });
212
+ await deployCommand({ preview: true });
202
213
 
203
214
  const jsonLine = writeSpy.mock.calls.map(c => c[0]).find(line => typeof line === 'string' && line.includes('"ok"'));
204
215
  expect(jsonLine).toBeDefined();
205
216
  const payload = JSON.parse(jsonLine as string);
206
- const previewSlug = buildPreviewSlug('my-app', 'existing-preview-id');
217
+ const previewSlug = buildPreviewSlug('my-app', STUB_PREVIEW_ID);
207
218
  expect(payload).toMatchObject({
208
219
  ok: true,
209
220
  appName: 'mf-my-app',
210
- previewId: 'existing-preview-id',
221
+ previewId: STUB_PREVIEW_ID,
211
222
  previewAppName: `mf-${previewSlug}`,
212
223
  previewUrl: `https://bigbrain.me/bigbrain-vibe/${previewSlug}`,
213
224
  });
@@ -1,5 +1,4 @@
1
1
  import os from 'node:os';
2
- import { randomUUID } from 'node:crypto';
3
2
  import { readJson } from 'fs-extra';
4
3
  import { info, warn, output, getOutputMode } from '../util/logger';
5
4
  import { resolveConsumerPaths } from '../shadow/paths';
@@ -22,6 +21,8 @@ import { setupS3Client } from '../util/s3/client';
22
21
  import { getMachineAppName } from '../util/machine-identity';
23
22
  import { registerOrUpdateApp } from '../util/broker/app';
24
23
  import { openInBrowser } from '../util/open-browser';
24
+ import { readMetadata } from '../shadow/metadata';
25
+ import { generatePreviewId } from '../shadow/prepare';
25
26
  import { buildCommand } from './build';
26
27
  import { promptForTags } from './tag';
27
28
  import type { ConsumerPaths } from '../types';
@@ -34,8 +35,6 @@ export interface DeployOptions {
34
35
  // Registers a real, disposable `apps` row under a synthetic name instead of
35
36
  // deploying — no git, no deploy lock (each previewId's slug is unique).
36
37
  preview?: boolean;
37
- // With --preview: update an existing preview instead of minting a new one.
38
- previewId?: string;
39
38
  open?: boolean;
40
39
  }
41
40
 
@@ -95,7 +94,9 @@ async function buildAndStamp(opts: {
95
94
  }
96
95
 
97
96
  async function deployPreview({ paths, consumerName, appName, token, opts }: DeployContext): Promise<void> {
98
- const previewId = opts.previewId ?? randomUUID().slice(0, 8);
97
+ const meta = await readMetadata(paths);
98
+ const previewId = meta?.previewId ?? generatePreviewId();
99
+
99
100
  const previewSlug = buildPreviewSlug(consumerName, previewId);
100
101
  const previewAppName = mfAppName(previewSlug);
101
102
  const keyPrefix = `${S3_PREVIEWS_FOLDER}/${previewAppName}/MF`;
@@ -108,12 +109,14 @@ async function deployPreview({ paths, consumerName, appName, token, opts }: Depl
108
109
  info('building');
109
110
  const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote: '' });
110
111
 
112
+ info('getting sts credentials');
111
113
  const creds = await brokerCredentialProvider({ appName: previewAppName, previewId })();
112
114
  setupS3Client(creds);
113
115
 
114
116
  info(`uploading preview build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
115
117
  await uploadDir(paths.shadowBuild, keyPrefix);
116
118
 
119
+ info('updating manifest');
117
120
  await registerOrUpdateApp({ appName: previewAppName, token, nextVersion, entry, gitRemote: '' });
118
121
 
119
122
  const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
@@ -205,8 +208,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
205
208
  // Acquire the deploy lock explicitly. Done after the build
206
209
  // so the lock hold stays tiny — it wraps only push → upload → manifest.
207
210
  // A 409 here means someone else is mid-deploy;
211
+ info('getting sts credentials');
208
212
  const creds = await brokerCredentialProvider({ appName })();
209
213
  setupS3Client(creds);
214
+ info('acquiring deploy lock');
210
215
  await acquireDeployLock(appName, token);
211
216
 
212
217
  // Push before any S3 write so a rejected push (someone deployed during our
@@ -219,8 +224,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
219
224
  info(`uploading build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
220
225
  await uploadDir(paths.shadowBuild, keyPrefix);
221
226
 
227
+ info('updating manifest');
222
228
  await registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote });
223
229
  } finally {
230
+ info('releasing deploy lock');
224
231
  await releaseLockBestEffort(appName, token);
225
232
  }
226
233
 
package/src/index.ts CHANGED
@@ -103,8 +103,8 @@ async function main(): Promise<void> {
103
103
  .description('Build, upload to S3, and register the app with the Z2H backend')
104
104
  .option('--dry-run', 'print what would happen without uploading anything')
105
105
  .option(
106
- '--preview [previewId]',
107
- 'build an ephemeral, shareable preview instead of deploying — no git required, no deploy lock, never touches the live version; pass an existing previewId to update it instead of minting a new one'
106
+ '--preview',
107
+ 'build an ephemeral, shareable preview instead of deploying — no git required, no deploy lock, never touches the live version; reuses the same preview URL across runs (stable link)'
108
108
  )
109
109
  .option('--open', 'open the resulting app/preview URL in the browser on success')
110
110
  .option(
@@ -116,22 +116,11 @@ async function main(): Promise<void> {
116
116
  'First deploy only: GitHub repo URL — required when --remote=github. Error if the app already has a git remote.'
117
117
  )
118
118
  .action(
119
- async (opts: {
120
- dryRun?: boolean;
121
- preview?: boolean | string;
122
- open?: boolean;
123
- remote?: string;
124
- repoUrl?: string;
125
- }) => {
119
+ async (opts: { dryRun?: boolean; preview?: boolean; open?: boolean; remote?: string; repoUrl?: string }) => {
126
120
  try {
127
121
  await trackDuration(
128
122
  opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success',
129
- () =>
130
- deployCommand({
131
- ...opts,
132
- preview: Boolean(opts.preview),
133
- previewId: typeof opts.preview === 'string' ? opts.preview : undefined,
134
- } as Parameters<typeof deployCommand>[0]),
123
+ () => deployCommand(opts as Parameters<typeof deployCommand>[0]),
135
124
  {
136
125
  app_name: getCwdAppName(),
137
126
  }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { ensureDir, pathExists, readJson } from 'fs-extra';
2
3
  import { info } from '../util/logger';
3
4
  import { cleanAppName } from '../util/app-name';
@@ -44,6 +45,10 @@ export async function prepareShadow(consumerDir: string = process.cwd()): Promis
44
45
  return { appName, port, paths };
45
46
  }
46
47
 
48
+ export function generatePreviewId(): string {
49
+ return randomUUID().slice(0, 8);
50
+ }
51
+
47
52
  /**
48
53
  * Resolve the dev server port. Prefer the persisted port if it's still free
49
54
  * (URL stays stable across restarts). Otherwise pick a fresh one. The
@@ -52,15 +57,22 @@ export async function prepareShadow(consumerDir: string = process.cwd()): Promis
52
57
  */
53
58
  async function resolvePortAndMetadata(paths: ConsumerPaths, appName: string): Promise<ShadowMetadata> {
54
59
  const existing = await readMetadata(paths);
55
- if (existing?.port && (await isPortFree(existing.port))) {
56
- return existing;
60
+
61
+ let port = existing?.port;
62
+ if (!port || !(await isPortFree(port))) {
63
+ port = await pickPort();
57
64
  }
58
65
 
59
66
  const next: ShadowMetadata = {
60
67
  createdAt: existing?.createdAt ?? new Date().toISOString(),
61
68
  appName,
62
- port: await pickPort(),
69
+ port,
70
+ previewId: existing?.previewId ?? generatePreviewId(),
63
71
  };
64
- await writeMetadata(paths, next);
72
+
73
+ if (JSON.stringify(next) !== JSON.stringify(existing)) {
74
+ await writeMetadata(paths, next);
75
+ }
76
+
65
77
  return next;
66
78
  }
package/src/types.ts CHANGED
@@ -34,6 +34,7 @@ export interface ShadowMetadata {
34
34
  createdAt: string;
35
35
  appName: string;
36
36
  port: number;
37
+ previewId: string;
37
38
  }
38
39
 
39
40
  // ─────────── Filesystem paths, resolved once per invocation ─────