@mondaydotcomorg/z2h-cli 0.21.1 → 0.21.2

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
  description?: string;
10
9
  }
@@ -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;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;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;AAWtG,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;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,7 @@ 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_prepare = require('../shadow/prepare.js');
21
21
  const commands_build = require('./build.js');
22
22
  const commands_tag = require('./tag.js');
23
23
 
@@ -63,7 +63,8 @@ async function buildAndStamp(opts) {
63
63
  };
64
64
  }
65
65
  async function deployPreview({ paths, consumerName, appName, token, opts }) {
66
- const previewId = opts.previewId ?? crypto.randomUUID().slice(0, 8);
66
+ await fs.ensureDir(paths.shadowDir);
67
+ const { previewId } = await shadow_prepare.resolvePortAndMetadata(paths, util_appName.cleanAppName(consumerName));
67
68
  const previewSlug = util_appName.buildPreviewSlug(consumerName, previewId);
68
69
  const previewAppName = util_appName.mfAppName(previewSlug);
69
70
  const keyPrefix = `${constants.S3_PREVIEWS_FOLDER}/${previewAppName}/MF`;
@@ -73,10 +74,12 @@ async function deployPreview({ paths, consumerName, appName, token, opts }) {
73
74
  const nextVersion = (await util_auth_brokerCredentialProvider.fetchDeployVersion(previewAppName)) + 1;
74
75
  util_logger.info('building');
75
76
  const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote: '' });
77
+ util_logger.info('getting sts credentials');
76
78
  const creds = await util_auth_brokerCredentialProvider.brokerCredentialProvider({ appName: previewAppName, previewId })();
77
79
  util_s3_client.setupS3Client(creds);
78
80
  util_logger.info(`uploading preview build → s3://${constants.BUCKET_WRITE_NAME}/${keyPrefix}/`);
79
81
  await util_s3_upload.uploadDir(paths.shadowBuild, keyPrefix);
82
+ util_logger.info('updating manifest');
80
83
  await util_broker_app.registerOrUpdateApp({ appName: previewAppName, token, nextVersion, entry, gitRemote: '' });
81
84
  const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
82
85
  util_logger.output(`[z2h-cli] preview ready ✅\n app: ${appName}\n previewAppName: ${previewAppName}\n open: ${previewUrl}`, {
@@ -151,8 +154,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }) {
151
154
  // Acquire the deploy lock explicitly. Done after the build
152
155
  // so the lock hold stays tiny — it wraps only push → upload → manifest.
153
156
  // A 409 here means someone else is mid-deploy;
157
+ util_logger.info('getting sts credentials');
154
158
  const creds = await util_auth_brokerCredentialProvider.brokerCredentialProvider({ appName })();
155
159
  util_s3_client.setupS3Client(creds);
160
+ util_logger.info('acquiring deploy lock');
156
161
  await acquireDeployLock(appName, token);
157
162
  // Push before any S3 write so a rejected push (someone deployed during our
158
163
  // build) stops the deploy before the bundle/manifest go live
@@ -162,9 +167,11 @@ async function deployLive({ paths, consumerName, appName, token, opts }) {
162
167
  }
163
168
  util_logger.info(`uploading build → s3://${constants.BUCKET_WRITE_NAME}/${keyPrefix}/`);
164
169
  await util_s3_upload.uploadDir(paths.shadowBuild, keyPrefix);
170
+ util_logger.info('updating manifest');
165
171
  await util_broker_app.registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote, description: opts.description });
166
172
  }
167
173
  finally {
174
+ util_logger.info('releasing deploy lock');
168
175
  await releaseLockBestEffort(appName, token);
169
176
  }
170
177
  // 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
  description?: string;
10
9
  }
@@ -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;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;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;AAWtG,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAWD,wBAAsB,aAAa,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB3E"}
@@ -1,6 +1,5 @@
1
1
  import os from 'node:os';
2
- import { randomUUID } from 'node:crypto';
3
- import { readJson } from 'fs-extra';
2
+ import { readJson, ensureDir } from 'fs-extra';
4
3
  import { info, output, getOutputMode, warn } from '../util/logger.mjs';
5
4
  import { resolveConsumerPaths } from '../shadow/paths.mjs';
6
5
  import { fetchAppTags, fetchTagCatalog, setAppTags } from '../util/tags.mjs';
@@ -16,6 +15,7 @@ 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 { resolvePortAndMetadata } from '../shadow/prepare.mjs';
19
19
  import { buildCommand } from './build.mjs';
20
20
  import { promptForTags } from './tag.mjs';
21
21
 
@@ -57,7 +57,8 @@ async function buildAndStamp(opts) {
57
57
  };
58
58
  }
59
59
  async function deployPreview({ paths, consumerName, appName, token, opts }) {
60
- const previewId = opts.previewId ?? randomUUID().slice(0, 8);
60
+ await ensureDir(paths.shadowDir);
61
+ const { previewId } = await resolvePortAndMetadata(paths, cleanAppName(consumerName));
61
62
  const previewSlug = buildPreviewSlug(consumerName, previewId);
62
63
  const previewAppName = mfAppName(previewSlug);
63
64
  const keyPrefix = `${S3_PREVIEWS_FOLDER}/${previewAppName}/MF`;
@@ -67,10 +68,12 @@ async function deployPreview({ paths, consumerName, appName, token, opts }) {
67
68
  const nextVersion = (await fetchDeployVersion(previewAppName)) + 1;
68
69
  info('building');
69
70
  const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote: '' });
71
+ info('getting sts credentials');
70
72
  const creds = await brokerCredentialProvider({ appName: previewAppName, previewId })();
71
73
  setupS3Client(creds);
72
74
  info(`uploading preview build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
73
75
  await uploadDir(paths.shadowBuild, keyPrefix);
76
+ info('updating manifest');
74
77
  await registerOrUpdateApp({ appName: previewAppName, token, nextVersion, entry, gitRemote: '' });
75
78
  const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
76
79
  output(`[z2h-cli] preview ready ✅\n app: ${appName}\n previewAppName: ${previewAppName}\n open: ${previewUrl}`, {
@@ -145,8 +148,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }) {
145
148
  // Acquire the deploy lock explicitly. Done after the build
146
149
  // so the lock hold stays tiny — it wraps only push → upload → manifest.
147
150
  // A 409 here means someone else is mid-deploy;
151
+ info('getting sts credentials');
148
152
  const creds = await brokerCredentialProvider({ appName })();
149
153
  setupS3Client(creds);
154
+ info('acquiring deploy lock');
150
155
  await acquireDeployLock(appName, token);
151
156
  // Push before any S3 write so a rejected push (someone deployed during our
152
157
  // build) stops the deploy before the bundle/manifest go live
@@ -156,9 +161,11 @@ async function deployLive({ paths, consumerName, appName, token, opts }) {
156
161
  }
157
162
  info(`uploading build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
158
163
  await uploadDir(paths.shadowBuild, keyPrefix);
164
+ info('updating manifest');
159
165
  await registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote, description: opts.description });
160
166
  }
161
167
  finally {
168
+ info('releasing deploy lock');
162
169
  await releaseLockBestEffort(appName, token);
163
170
  }
164
171
  // Tags are a post-deploy concern: the S3 upload has already succeeded, so a
@@ -93,18 +93,14 @@ 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
  .option('--description <text>', 'short (<=200 char) description of the app')
101
101
  .action(async (opts) => {
102
102
  try {
103
- await trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => deployCommand({
104
- ...opts,
105
- preview: Boolean(opts.preview),
106
- previewId: typeof opts.preview === 'string' ? opts.preview : undefined,
107
- }), {
103
+ await trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => deployCommand(opts), {
108
104
  app_name: getCwdAppName(),
109
105
  });
110
106
  }
@@ -1,3 +1,10 @@
1
- import type { PreparedShadow } from '../types';
1
+ import type { ConsumerPaths, PreparedShadow, ShadowMetadata } from '../types';
2
2
  export declare function prepareShadow(consumerDir?: string): Promise<PreparedShadow>;
3
+ /**
4
+ * Resolve the dev server port. Prefer the persisted port if it's still free
5
+ * (URL stays stable across restarts). Otherwise pick a fresh one. The
6
+ * orphaned process — if there is one — keeps running on its old port,
7
+ * harmless. No process killing, no PID files.
8
+ */
9
+ export declare function resolvePortAndMetadata(paths: ConsumerPaths, cleanAppName: string): Promise<ShadowMetadata>;
3
10
  //# 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,EAAE,aAAa,EAAe,cAAc,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE3F,wBAAsB,aAAa,CAAC,WAAW,GAAE,MAAsB,GAAG,OAAO,CAAC,cAAc,CAAC,CAgChG;AAMD;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAAC,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAoBhH"}
@@ -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,24 +36,31 @@ 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
41
45
  * orphaned process — if there is one — keeps running on its old port,
42
46
  * harmless. No process killing, no PID files.
43
47
  */
44
- async function resolvePortAndMetadata(paths, appName) {
48
+ async function resolvePortAndMetadata(paths, cleanAppName) {
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
- appName,
52
- port: await pickPort(),
56
+ appName: cleanAppName,
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 { prepareShadow, resolvePortAndMetadata };
@@ -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,18 +99,14 @@ 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
  .option('--description <text>', 'short (<=200 char) description of the app')
107
107
  .action(async (opts) => {
108
108
  try {
109
- await util_tracker.trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => commands_deploy.deployCommand({
110
- ...opts,
111
- preview: Boolean(opts.preview),
112
- previewId: typeof opts.preview === 'string' ? opts.preview : undefined,
113
- }), {
109
+ await util_tracker.trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => commands_deploy.deployCommand(opts), {
114
110
  app_name: util_appName.getCwdAppName(),
115
111
  });
116
112
  }
@@ -1,3 +1,10 @@
1
- import type { PreparedShadow } from '../types';
1
+ import type { ConsumerPaths, PreparedShadow, ShadowMetadata } from '../types';
2
2
  export declare function prepareShadow(consumerDir?: string): Promise<PreparedShadow>;
3
+ /**
4
+ * Resolve the dev server port. Prefer the persisted port if it's still free
5
+ * (URL stays stable across restarts). Otherwise pick a fresh one. The
6
+ * orphaned process — if there is one — keeps running on its old port,
7
+ * harmless. No process killing, no PID files.
8
+ */
9
+ export declare function resolvePortAndMetadata(paths: ConsumerPaths, cleanAppName: string): Promise<ShadowMetadata>;
3
10
  //# 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,EAAE,aAAa,EAAe,cAAc,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE3F,wBAAsB,aAAa,CAAC,WAAW,GAAE,MAAsB,GAAG,OAAO,CAAC,cAAc,CAAC,CAgChG;AAMD;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAAC,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAoBhH"}
@@ -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,24 +38,32 @@ 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
43
47
  * orphaned process — if there is one — keeps running on its old port,
44
48
  * harmless. No process killing, no PID files.
45
49
  */
46
- async function resolvePortAndMetadata(paths, appName) {
50
+ async function resolvePortAndMetadata(paths, cleanAppName) {
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
- appName,
54
- port: await shadow_port.pickPort(),
58
+ appName: cleanAppName,
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
 
60
68
  exports.prepareShadow = prepareShadow;
69
+ exports.resolvePortAndMetadata = resolvePortAndMetadata;
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.21.1",
3
+ "version": "0.21.2",
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,6 +1,5 @@
1
1
  import os from 'node:os';
2
- import { randomUUID } from 'node:crypto';
3
- import { readJson } from 'fs-extra';
2
+ import { ensureDir, readJson } from 'fs-extra';
4
3
  import { info, warn, output, getOutputMode } from '../util/logger';
5
4
  import { resolveConsumerPaths } from '../shadow/paths';
6
5
  import { fetchAppTags, fetchTagCatalog, setAppTags } from '../util/tags';
@@ -22,6 +21,7 @@ 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 { resolvePortAndMetadata } from '../shadow/prepare';
25
25
  import { buildCommand } from './build';
26
26
  import { promptForTags } from './tag';
27
27
  import type { ConsumerPaths } from '../types';
@@ -31,11 +31,8 @@ export interface DeployOptions {
31
31
  dryRun?: boolean;
32
32
  remote?: RemoteType;
33
33
  repoUrl?: string;
34
- // Registers a real, disposable `apps` row under a synthetic name instead of
35
- // deploying — no git, no deploy lock (each previewId's slug is unique).
34
+ // Registers a real, disposable `apps` row under a synthetic name instead of deploying as a live app
36
35
  preview?: boolean;
37
- // With --preview: update an existing preview instead of minting a new one.
38
- previewId?: string;
39
36
  open?: boolean;
40
37
  description?: string;
41
38
  }
@@ -96,7 +93,9 @@ async function buildAndStamp(opts: {
96
93
  }
97
94
 
98
95
  async function deployPreview({ paths, consumerName, appName, token, opts }: DeployContext): Promise<void> {
99
- const previewId = opts.previewId ?? randomUUID().slice(0, 8);
96
+ await ensureDir(paths.shadowDir);
97
+ const { previewId } = await resolvePortAndMetadata(paths, cleanAppName(consumerName));
98
+
100
99
  const previewSlug = buildPreviewSlug(consumerName, previewId);
101
100
  const previewAppName = mfAppName(previewSlug);
102
101
  const keyPrefix = `${S3_PREVIEWS_FOLDER}/${previewAppName}/MF`;
@@ -109,12 +108,14 @@ async function deployPreview({ paths, consumerName, appName, token, opts }: Depl
109
108
  info('building');
110
109
  const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote: '' });
111
110
 
111
+ info('getting sts credentials');
112
112
  const creds = await brokerCredentialProvider({ appName: previewAppName, previewId })();
113
113
  setupS3Client(creds);
114
114
 
115
115
  info(`uploading preview build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
116
116
  await uploadDir(paths.shadowBuild, keyPrefix);
117
117
 
118
+ info('updating manifest');
118
119
  await registerOrUpdateApp({ appName: previewAppName, token, nextVersion, entry, gitRemote: '' });
119
120
 
120
121
  const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
@@ -206,8 +207,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
206
207
  // Acquire the deploy lock explicitly. Done after the build
207
208
  // so the lock hold stays tiny — it wraps only push → upload → manifest.
208
209
  // A 409 here means someone else is mid-deploy;
210
+ info('getting sts credentials');
209
211
  const creds = await brokerCredentialProvider({ appName })();
210
212
  setupS3Client(creds);
213
+ info('acquiring deploy lock');
211
214
  await acquireDeployLock(appName, token);
212
215
 
213
216
  // Push before any S3 write so a rejected push (someone deployed during our
@@ -220,8 +223,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
220
223
  info(`uploading build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
221
224
  await uploadDir(paths.shadowBuild, keyPrefix);
222
225
 
226
+ info('updating manifest');
223
227
  await registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote, description: opts.description });
224
228
  } finally {
229
+ info('releasing deploy lock');
225
230
  await releaseLockBestEffort(appName, token);
226
231
  }
227
232
 
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(
@@ -128,12 +128,7 @@ async function main(): Promise<void> {
128
128
  try {
129
129
  await trackDuration(
130
130
  opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success',
131
- () =>
132
- deployCommand({
133
- ...opts,
134
- preview: Boolean(opts.preview),
135
- previewId: typeof opts.preview === 'string' ? opts.preview : undefined,
136
- } as Parameters<typeof deployCommand>[0]),
131
+ () => deployCommand(opts as Parameters<typeof deployCommand>[0]),
137
132
  {
138
133
  app_name: getCwdAppName(),
139
134
  }
@@ -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,23 +45,34 @@ export async function prepareShadow(consumerDir: string = process.cwd()): Promis
44
45
  return { appName, port, paths };
45
46
  }
46
47
 
48
+ 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
50
55
  * orphaned process — if there is one — keeps running on its old port,
51
56
  * harmless. No process killing, no PID files.
52
57
  */
53
- async function resolvePortAndMetadata(paths: ConsumerPaths, appName: string): Promise<ShadowMetadata> {
58
+ export async function resolvePortAndMetadata(paths: ConsumerPaths, cleanAppName: 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
- appName,
62
- port: await pickPort(),
68
+ appName: cleanAppName,
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 ─────