@mondaydotcomorg/z2h-cli 0.21.0 → 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,8 +4,8 @@ export interface DeployOptions {
4
4
  remote?: RemoteType;
5
5
  repoUrl?: string;
6
6
  preview?: boolean;
7
- previewId?: string;
8
7
  open?: boolean;
8
+ description?: string;
9
9
  }
10
10
  export declare function deployCommand(opts?: DeployOptions): Promise<void>;
11
11
  //# sourceMappingURL=deploy.d.ts.map
@@ -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;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);
165
- await util_broker_app.registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote });
170
+ util_logger.info('updating manifest');
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,8 +4,8 @@ export interface DeployOptions {
4
4
  remote?: RemoteType;
5
5
  repoUrl?: string;
6
6
  preview?: boolean;
7
- previewId?: string;
8
7
  open?: boolean;
8
+ description?: string;
9
9
  }
10
10
  export declare function deployCommand(opts?: DeployOptions): Promise<void>;
11
11
  //# sourceMappingURL=deploy.d.ts.map
@@ -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;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);
159
- await registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote });
164
+ info('updating manifest');
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,17 +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
+ .option('--description <text>', 'short (<=200 char) description of the app')
100
101
  .action(async (opts) => {
101
102
  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
- }), {
103
+ await trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => deployCommand(opts), {
107
104
  app_name: getCwdAppName(),
108
105
  });
109
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"}
@@ -18,6 +18,7 @@ export interface RegisterOrUpdateOptions {
18
18
  nextVersion: number;
19
19
  entry: ManifestEntry;
20
20
  gitRemote: string;
21
+ description?: string;
21
22
  }
22
23
  /**
23
24
  * v1 creates the app row (+ owner grant, prompting for visibility); later
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/util/broker/app.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAwCD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAWtF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/util/broker/app.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AA0CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAWtF"}
@@ -28,11 +28,11 @@ async function fetchApp(appName) {
28
28
  }
29
29
  return res.json();
30
30
  }
31
- async function registerApp(appName, token, version, entry, visibility, gitRemote) {
31
+ async function registerApp(appName, token, version, entry, visibility, gitRemote, description) {
32
32
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/register`, {
33
33
  method: 'POST',
34
34
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
35
- body: JSON.stringify({ appName, version, entry, visibility, gitRemote }),
35
+ body: JSON.stringify({ appName, version, entry, visibility, gitRemote, description }),
36
36
  });
37
37
  if (!res.ok) {
38
38
  const text = await res.text();
@@ -40,11 +40,11 @@ async function registerApp(appName, token, version, entry, visibility, gitRemote
40
40
  }
41
41
  }
42
42
  /** Releases a new version onto an app that already exists. */
43
- async function updateApp(appName, token, version, entry, gitRemote) {
43
+ async function updateApp(appName, token, version, entry, gitRemote, description) {
44
44
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
45
45
  method: 'POST',
46
46
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
47
- body: JSON.stringify({ appName, version, entry, gitRemote }),
47
+ body: JSON.stringify({ appName, version, entry, gitRemote, description }),
48
48
  });
49
49
  if (!res.ok) {
50
50
  const text = await res.text();
@@ -58,15 +58,15 @@ async function updateApp(appName, token, version, entry, gitRemote) {
58
58
  * deploy and preview so this branching logic lives in exactly one place.
59
59
  */
60
60
  async function registerOrUpdateApp(opts) {
61
- const { appName, token, nextVersion, entry, gitRemote } = opts;
61
+ const { appName, token, nextVersion, entry, gitRemote, description } = opts;
62
62
  if (nextVersion === 1) {
63
63
  const visibility = await resolveAppVisibility(appName);
64
64
  info(`registering "${appName}" → v${nextVersion} (${visibility})`);
65
- await registerApp(appName, token, nextVersion, entry, visibility, gitRemote);
65
+ await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, description);
66
66
  }
67
67
  else {
68
68
  info(`updating "${appName}" → v${nextVersion}`);
69
- await updateApp(appName, token, nextVersion, entry, gitRemote);
69
+ await updateApp(appName, token, nextVersion, entry, gitRemote, description);
70
70
  }
71
71
  }
72
72
 
package/dist/index.js CHANGED
@@ -99,17 +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
+ .option('--description <text>', 'short (<=200 char) description of the app')
106
107
  .action(async (opts) => {
107
108
  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
- }), {
109
+ await util_tracker.trackDuration(opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success', () => commands_deploy.deployCommand(opts), {
113
110
  app_name: util_appName.getCwdAppName(),
114
111
  });
115
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"}
@@ -18,6 +18,7 @@ export interface RegisterOrUpdateOptions {
18
18
  nextVersion: number;
19
19
  entry: ManifestEntry;
20
20
  gitRemote: string;
21
+ description?: string;
21
22
  }
22
23
  /**
23
24
  * v1 creates the app row (+ owner grant, prompting for visibility); later
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../src/util/broker/app.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAwCD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAWtF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../src/util/broker/app.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AA0CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAWtF"}
@@ -30,11 +30,11 @@ async function fetchApp(appName) {
30
30
  }
31
31
  return res.json();
32
32
  }
33
- async function registerApp(appName, token, version, entry, visibility, gitRemote) {
33
+ async function registerApp(appName, token, version, entry, visibility, gitRemote, description) {
34
34
  const res = await fetch(`${constants.brokerBaseUrl()}/z2h-cli/apps/register`, {
35
35
  method: 'POST',
36
36
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...constants.routingHeaders() },
37
- body: JSON.stringify({ appName, version, entry, visibility, gitRemote }),
37
+ body: JSON.stringify({ appName, version, entry, visibility, gitRemote, description }),
38
38
  });
39
39
  if (!res.ok) {
40
40
  const text = await res.text();
@@ -42,11 +42,11 @@ async function registerApp(appName, token, version, entry, visibility, gitRemote
42
42
  }
43
43
  }
44
44
  /** Releases a new version onto an app that already exists. */
45
- async function updateApp(appName, token, version, entry, gitRemote) {
45
+ async function updateApp(appName, token, version, entry, gitRemote, description) {
46
46
  const res = await fetch(`${constants.brokerBaseUrl()}/z2h-cli/apps/update`, {
47
47
  method: 'POST',
48
48
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...constants.routingHeaders() },
49
- body: JSON.stringify({ appName, version, entry, gitRemote }),
49
+ body: JSON.stringify({ appName, version, entry, gitRemote, description }),
50
50
  });
51
51
  if (!res.ok) {
52
52
  const text = await res.text();
@@ -60,15 +60,15 @@ async function updateApp(appName, token, version, entry, gitRemote) {
60
60
  * deploy and preview so this branching logic lives in exactly one place.
61
61
  */
62
62
  async function registerOrUpdateApp(opts) {
63
- const { appName, token, nextVersion, entry, gitRemote } = opts;
63
+ const { appName, token, nextVersion, entry, gitRemote, description } = opts;
64
64
  if (nextVersion === 1) {
65
65
  const visibility = await util_promptVisibility.resolveAppVisibility(appName);
66
66
  util_logger.info(`registering "${appName}" → v${nextVersion} (${visibility})`);
67
- await registerApp(appName, token, nextVersion, entry, visibility, gitRemote);
67
+ await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, description);
68
68
  }
69
69
  else {
70
70
  util_logger.info(`updating "${appName}" → v${nextVersion}`);
71
- await updateApp(appName, token, nextVersion, entry, gitRemote);
71
+ await updateApp(appName, token, nextVersion, entry, gitRemote, description);
72
72
  }
73
73
  }
74
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.21.0",
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,12 +31,10 @@ 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;
37
+ description?: string;
40
38
  }
41
39
 
42
40
  interface DeployContext {
@@ -95,7 +93,9 @@ async function buildAndStamp(opts: {
95
93
  }
96
94
 
97
95
  async function deployPreview({ paths, consumerName, appName, token, opts }: DeployContext): Promise<void> {
98
- const previewId = opts.previewId ?? randomUUID().slice(0, 8);
96
+ await ensureDir(paths.shadowDir);
97
+ const { previewId } = await resolvePortAndMetadata(paths, cleanAppName(consumerName));
98
+
99
99
  const previewSlug = buildPreviewSlug(consumerName, previewId);
100
100
  const previewAppName = mfAppName(previewSlug);
101
101
  const keyPrefix = `${S3_PREVIEWS_FOLDER}/${previewAppName}/MF`;
@@ -108,12 +108,14 @@ async function deployPreview({ paths, consumerName, appName, token, opts }: Depl
108
108
  info('building');
109
109
  const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote: '' });
110
110
 
111
+ info('getting sts credentials');
111
112
  const creds = await brokerCredentialProvider({ appName: previewAppName, previewId })();
112
113
  setupS3Client(creds);
113
114
 
114
115
  info(`uploading preview build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
115
116
  await uploadDir(paths.shadowBuild, keyPrefix);
116
117
 
118
+ info('updating manifest');
117
119
  await registerOrUpdateApp({ appName: previewAppName, token, nextVersion, entry, gitRemote: '' });
118
120
 
119
121
  const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
@@ -205,8 +207,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
205
207
  // Acquire the deploy lock explicitly. Done after the build
206
208
  // so the lock hold stays tiny — it wraps only push → upload → manifest.
207
209
  // A 409 here means someone else is mid-deploy;
210
+ info('getting sts credentials');
208
211
  const creds = await brokerCredentialProvider({ appName })();
209
212
  setupS3Client(creds);
213
+ info('acquiring deploy lock');
210
214
  await acquireDeployLock(appName, token);
211
215
 
212
216
  // Push before any S3 write so a rejected push (someone deployed during our
@@ -219,8 +223,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
219
223
  info(`uploading build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
220
224
  await uploadDir(paths.shadowBuild, keyPrefix);
221
225
 
222
- await registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote });
226
+ info('updating manifest');
227
+ await registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote, description: opts.description });
223
228
  } finally {
229
+ info('releasing deploy lock');
224
230
  await releaseLockBestEffort(appName, token);
225
231
  }
226
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(
@@ -115,6 +115,7 @@ async function main(): Promise<void> {
115
115
  '--repo-url <url>',
116
116
  'First deploy only: GitHub repo URL — required when --remote=github. Error if the app already has a git remote.'
117
117
  )
118
+ .option('--description <text>', 'short (<=200 char) description of the app')
118
119
  .action(
119
120
  async (opts: {
120
121
  dryRun?: boolean;
@@ -122,16 +123,12 @@ async function main(): Promise<void> {
122
123
  open?: boolean;
123
124
  remote?: string;
124
125
  repoUrl?: string;
126
+ description?: string;
125
127
  }) => {
126
128
  try {
127
129
  await trackDuration(
128
130
  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]),
131
+ () => deployCommand(opts as Parameters<typeof deployCommand>[0]),
135
132
  {
136
133
  app_name: getCwdAppName(),
137
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 ─────
@@ -47,12 +47,13 @@ async function registerApp(
47
47
  version: number,
48
48
  entry: ManifestEntry,
49
49
  visibility: 'public' | 'private',
50
- gitRemote: string
50
+ gitRemote: string,
51
+ description?: string
51
52
  ): Promise<void> {
52
53
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/register`, {
53
54
  method: 'POST',
54
55
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
55
- body: JSON.stringify({ appName, version, entry, visibility, gitRemote }),
56
+ body: JSON.stringify({ appName, version, entry, visibility, gitRemote, description }),
56
57
  });
57
58
  if (!res.ok) {
58
59
  const text = await res.text();
@@ -66,12 +67,13 @@ async function updateApp(
66
67
  token: string,
67
68
  version: number,
68
69
  entry: ManifestEntry,
69
- gitRemote: string
70
+ gitRemote: string,
71
+ description?: string
70
72
  ): Promise<void> {
71
73
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
72
74
  method: 'POST',
73
75
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
74
- body: JSON.stringify({ appName, version, entry, gitRemote }),
76
+ body: JSON.stringify({ appName, version, entry, gitRemote, description }),
75
77
  });
76
78
  if (!res.ok) {
77
79
  const text = await res.text();
@@ -85,6 +87,7 @@ export interface RegisterOrUpdateOptions {
85
87
  nextVersion: number;
86
88
  entry: ManifestEntry;
87
89
  gitRemote: string;
90
+ description?: string;
88
91
  }
89
92
 
90
93
  /**
@@ -94,14 +97,14 @@ export interface RegisterOrUpdateOptions {
94
97
  * deploy and preview so this branching logic lives in exactly one place.
95
98
  */
96
99
  export async function registerOrUpdateApp(opts: RegisterOrUpdateOptions): Promise<void> {
97
- const { appName, token, nextVersion, entry, gitRemote } = opts;
100
+ const { appName, token, nextVersion, entry, gitRemote, description } = opts;
98
101
 
99
102
  if (nextVersion === 1) {
100
103
  const visibility = await resolveAppVisibility(appName);
101
104
  info(`registering "${appName}" → v${nextVersion} (${visibility})`);
102
- await registerApp(appName, token, nextVersion, entry, visibility, gitRemote);
105
+ await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, description);
103
106
  } else {
104
107
  info(`updating "${appName}" → v${nextVersion}`);
105
- await updateApp(appName, token, nextVersion, entry, gitRemote);
108
+ await updateApp(appName, token, nextVersion, entry, gitRemote, description);
106
109
  }
107
110
  }