@mondaydotcomorg/z2h-cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/commands/__tests__/edit.test.d.ts +2 -0
  2. package/dist/commands/__tests__/edit.test.d.ts.map +1 -0
  3. package/dist/commands/create.d.ts +2 -2
  4. package/dist/commands/create.d.ts.map +1 -1
  5. package/dist/commands/create.js +14 -34
  6. package/dist/commands/deploy.d.ts +3 -0
  7. package/dist/commands/deploy.d.ts.map +1 -1
  8. package/dist/commands/deploy.js +123 -23
  9. package/dist/commands/edit.d.ts +5 -0
  10. package/dist/commands/edit.d.ts.map +1 -0
  11. package/dist/commands/edit.js +61 -0
  12. package/dist/commands/grant.d.ts +12 -0
  13. package/dist/commands/grant.d.ts.map +1 -0
  14. package/dist/commands/grant.js +94 -0
  15. package/dist/constants.d.ts +1 -0
  16. package/dist/constants.d.ts.map +1 -1
  17. package/dist/constants.js +7 -0
  18. package/dist/esm/commands/__tests__/edit.test.d.ts +2 -0
  19. package/dist/esm/commands/__tests__/edit.test.d.ts.map +1 -0
  20. package/dist/esm/commands/create.d.ts +2 -2
  21. package/dist/esm/commands/create.d.ts.map +1 -1
  22. package/dist/esm/commands/create.mjs +16 -35
  23. package/dist/esm/commands/deploy.d.ts +3 -0
  24. package/dist/esm/commands/deploy.d.ts.map +1 -1
  25. package/dist/esm/commands/deploy.mjs +124 -24
  26. package/dist/esm/commands/edit.d.ts +5 -0
  27. package/dist/esm/commands/edit.d.ts.map +1 -0
  28. package/dist/esm/commands/edit.mjs +55 -0
  29. package/dist/esm/commands/grant.d.ts +12 -0
  30. package/dist/esm/commands/grant.d.ts.map +1 -0
  31. package/dist/esm/commands/grant.mjs +90 -0
  32. package/dist/esm/constants.d.ts +1 -0
  33. package/dist/esm/constants.d.ts.map +1 -1
  34. package/dist/esm/constants.mjs +7 -1
  35. package/dist/esm/index.mjs +67 -10
  36. package/dist/esm/shadow/prepare.d.ts +1 -1
  37. package/dist/esm/shadow/prepare.d.ts.map +1 -1
  38. package/dist/esm/shadow/prepare.mjs +2 -2
  39. package/dist/esm/util/app-name.d.ts +1 -0
  40. package/dist/esm/util/app-name.d.ts.map +1 -1
  41. package/dist/esm/util/app-name.mjs +11 -1
  42. package/dist/esm/util/auth/auth-store.d.ts.map +1 -1
  43. package/dist/esm/util/auth/auth-store.mjs +10 -0
  44. package/dist/esm/util/auth/broker-credential-provider.d.ts +10 -2
  45. package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
  46. package/dist/esm/util/auth/broker-credential-provider.mjs +35 -13
  47. package/dist/esm/util/broker/app.d.ts +14 -0
  48. package/dist/esm/util/broker/app.d.ts.map +1 -0
  49. package/dist/esm/util/broker/app.mjs +27 -0
  50. package/dist/esm/util/git/git-env.d.ts +9 -0
  51. package/dist/esm/util/git/git-env.d.ts.map +1 -0
  52. package/dist/esm/util/git/git-env.mjs +22 -0
  53. package/dist/esm/util/git/remote-options.d.ts +17 -0
  54. package/dist/esm/util/git/remote-options.d.ts.map +1 -0
  55. package/dist/esm/util/git/remote-options.mjs +30 -0
  56. package/dist/esm/util/git/repo.d.ts +21 -3
  57. package/dist/esm/util/git/repo.d.ts.map +1 -1
  58. package/dist/esm/util/git/repo.mjs +103 -47
  59. package/dist/esm/util/machine-identity.d.ts +9 -0
  60. package/dist/esm/util/machine-identity.d.ts.map +1 -0
  61. package/dist/esm/util/machine-identity.mjs +12 -0
  62. package/dist/esm/util/s3/client.d.ts +2 -7
  63. package/dist/esm/util/s3/client.d.ts.map +1 -1
  64. package/dist/esm/util/s3/client.mjs +3 -11
  65. package/dist/index.js +69 -12
  66. package/dist/shadow/prepare.d.ts +1 -1
  67. package/dist/shadow/prepare.d.ts.map +1 -1
  68. package/dist/shadow/prepare.js +2 -2
  69. package/dist/util/app-name.d.ts +1 -0
  70. package/dist/util/app-name.d.ts.map +1 -1
  71. package/dist/util/app-name.js +11 -0
  72. package/dist/util/auth/auth-store.d.ts.map +1 -1
  73. package/dist/util/auth/auth-store.js +10 -0
  74. package/dist/util/auth/broker-credential-provider.d.ts +10 -2
  75. package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
  76. package/dist/util/auth/broker-credential-provider.js +35 -11
  77. package/dist/util/broker/app.d.ts +14 -0
  78. package/dist/util/broker/app.d.ts.map +1 -0
  79. package/dist/util/broker/app.js +29 -0
  80. package/dist/util/git/git-env.d.ts +9 -0
  81. package/dist/util/git/git-env.d.ts.map +1 -0
  82. package/dist/util/git/git-env.js +24 -0
  83. package/dist/util/git/remote-options.d.ts +17 -0
  84. package/dist/util/git/remote-options.d.ts.map +1 -0
  85. package/dist/util/git/remote-options.js +34 -0
  86. package/dist/util/git/repo.d.ts +21 -3
  87. package/dist/util/git/repo.d.ts.map +1 -1
  88. package/dist/util/git/repo.js +104 -47
  89. package/dist/util/machine-identity.d.ts +9 -0
  90. package/dist/util/machine-identity.d.ts.map +1 -0
  91. package/dist/util/machine-identity.js +14 -0
  92. package/dist/util/s3/client.d.ts +2 -7
  93. package/dist/util/s3/client.d.ts.map +1 -1
  94. package/dist/util/s3/client.js +3 -11
  95. package/package.json +1 -1
  96. package/src/commands/__tests__/create.test.ts +9 -7
  97. package/src/commands/__tests__/edit.test.ts +184 -0
  98. package/src/commands/create.ts +15 -33
  99. package/src/commands/deploy.ts +147 -33
  100. package/src/commands/edit.ts +63 -0
  101. package/src/commands/grant.ts +115 -0
  102. package/src/constants.ts +7 -0
  103. package/src/index.ts +83 -13
  104. package/src/shadow/prepare.ts +2 -2
  105. package/src/templates/app/CLAUDE.md +15 -0
  106. package/src/util/app-name.ts +10 -0
  107. package/src/util/auth/__tests__/broker-credential-provider.test.ts +34 -2
  108. package/src/util/auth/auth-store.ts +12 -0
  109. package/src/util/auth/broker-credential-provider.ts +43 -15
  110. package/src/util/broker/app.ts +34 -0
  111. package/src/util/git/__tests__/repo.test.ts +108 -90
  112. package/src/util/git/git-env.ts +23 -0
  113. package/src/util/git/remote-options.ts +37 -0
  114. package/src/util/git/repo.ts +121 -47
  115. package/src/util/machine-identity.ts +10 -0
  116. package/src/util/s3/client.ts +4 -11
@@ -0,0 +1,184 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { mkdtemp, rm, ensureDir, writeJson } from 'fs-extra';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { runYarnInstall } from '../../util/npm/install';
7
+ import { prepareShadow } from '../../shadow/prepare';
8
+ import { editCommand } from '../edit';
9
+ import * as brokerApp from '../../util/broker/app';
10
+ import * as repo from '../../util/git/repo';
11
+ import * as brokerProvider from '../../util/auth/broker-credential-provider';
12
+
13
+ vi.mock('../../util/npm/install', () => ({
14
+ runYarnInstall: vi.fn().mockResolvedValue(undefined),
15
+ }));
16
+
17
+ vi.mock('../../shadow/prepare', () => ({
18
+ prepareShadow: vi.fn().mockResolvedValue(undefined),
19
+ }));
20
+
21
+ vi.mock('../../util/git/repo', () => ({
22
+ cloneRepo: vi.fn().mockResolvedValue(undefined),
23
+ pullAndResolve: vi.fn().mockResolvedValue(undefined),
24
+ GitConflictError: class GitConflictError extends Error {},
25
+ }));
26
+
27
+ const FAKE_CREDS = { accessKeyId: 'AKID', secretAccessKey: 'SECRET', sessionToken: 'TOKEN' };
28
+ const FAKE_GIT_ENV = { AWS_ACCESS_KEY_ID: 'AKID', AWS_SECRET_ACCESS_KEY: 'SECRET', AWS_SESSION_TOKEN: 'TOKEN' };
29
+
30
+ vi.mock('../../util/auth/broker-credential-provider', () => ({
31
+ brokerCredentialProvider: vi.fn(() => vi.fn().mockResolvedValue(FAKE_CREDS)),
32
+ }));
33
+
34
+ const S3_REMOTE = 's3://prod-use1-bigbrain-zth-mf-assets/mf-demo/git';
35
+ const GITHUB_REMOTE = 'https://github.com/org/demo-app.git';
36
+
37
+ async function makeWorkspace(tmp: string): Promise<void> {
38
+ await ensureDir(tmp);
39
+ await writeJson(path.join(tmp, 'package.json'), {
40
+ name: '@mondaydotcomorg/zth-projects-workspace',
41
+ private: true,
42
+ z2hWorkspace: true,
43
+ workspaces: ['*', '*/.zth'],
44
+ });
45
+ }
46
+
47
+ describe('edit', () => {
48
+ let tmp: string;
49
+
50
+ beforeEach(async () => {
51
+ tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-e-'));
52
+ vi.stubEnv('Z2H_WORKSPACE_HOME', tmp);
53
+ vi.mocked(runYarnInstall).mockClear();
54
+ vi.mocked(prepareShadow).mockClear();
55
+ vi.mocked(repo.cloneRepo).mockClear();
56
+ vi.mocked(repo.pullAndResolve).mockClear();
57
+ vi.mocked(brokerProvider.brokerCredentialProvider).mockClear();
58
+ });
59
+
60
+ afterEach(async () => {
61
+ vi.unstubAllEnvs();
62
+ await rm(tmp, { recursive: true, force: true });
63
+ });
64
+
65
+ it('skips cleanly when the app has no gitRemote', async () => {
66
+ await makeWorkspace(tmp);
67
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue(null);
68
+
69
+ await expect(editCommand('demo')).resolves.toBeUndefined();
70
+ expect(repo.cloneRepo).not.toHaveBeenCalled();
71
+ expect(repo.pullAndResolve).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('clones when the app dir does not exist locally', async () => {
75
+ await makeWorkspace(tmp);
76
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
77
+ appName: 'mf-demo',
78
+ version: 3,
79
+ gitRemote: S3_REMOTE,
80
+ entry: {},
81
+ deployedBy: 'u@x.com',
82
+ createdBy: 'u@x.com',
83
+ });
84
+
85
+ await editCommand('demo');
86
+
87
+ expect(repo.cloneRepo).toHaveBeenCalledWith(
88
+ S3_REMOTE,
89
+ path.join(tmp, 'demo'),
90
+ expect.objectContaining(FAKE_GIT_ENV)
91
+ );
92
+ expect(repo.pullAndResolve).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('pulls when the app dir already exists locally', async () => {
96
+ await makeWorkspace(tmp);
97
+ await ensureDir(path.join(tmp, 'demo'));
98
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
99
+ appName: 'mf-demo',
100
+ version: 3,
101
+ gitRemote: S3_REMOTE,
102
+ entry: {},
103
+ deployedBy: 'u@x.com',
104
+ createdBy: 'u@x.com',
105
+ });
106
+
107
+ await editCommand('demo');
108
+
109
+ expect(repo.pullAndResolve).toHaveBeenCalledWith(path.join(tmp, 'demo'), expect.objectContaining(FAKE_GIT_ENV));
110
+ expect(repo.cloneRepo).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('injects AWS credentials for S3 remotes', async () => {
114
+ await makeWorkspace(tmp);
115
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
116
+ appName: 'mf-demo',
117
+ version: 3,
118
+ gitRemote: S3_REMOTE,
119
+ entry: {},
120
+ deployedBy: 'u@x.com',
121
+ createdBy: 'u@x.com',
122
+ });
123
+
124
+ await editCommand('demo');
125
+
126
+ expect(brokerProvider.brokerCredentialProvider).toHaveBeenCalledWith({ appName: 'mf-demo' });
127
+ expect(repo.cloneRepo).toHaveBeenCalledWith(S3_REMOTE, expect.any(String), expect.objectContaining(FAKE_GIT_ENV));
128
+ });
129
+
130
+ it('does not inject AWS credentials for GitHub remotes', async () => {
131
+ await makeWorkspace(tmp);
132
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
133
+ appName: 'mf-demo',
134
+ version: 3,
135
+ gitRemote: GITHUB_REMOTE,
136
+ entry: {},
137
+ deployedBy: 'u@x.com',
138
+ createdBy: 'u@x.com',
139
+ });
140
+
141
+ await editCommand('demo');
142
+
143
+ expect(brokerProvider.brokerCredentialProvider).not.toHaveBeenCalled();
144
+ expect(repo.cloneRepo).toHaveBeenCalledWith(GITHUB_REMOTE, expect.any(String), undefined);
145
+ });
146
+
147
+ it('re-wires the workspace after a fresh clone', async () => {
148
+ await makeWorkspace(tmp);
149
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
150
+ appName: 'mf-demo',
151
+ version: 3,
152
+ gitRemote: S3_REMOTE,
153
+ entry: {},
154
+ deployedBy: 'u@x.com',
155
+ createdBy: 'u@x.com',
156
+ });
157
+ vi.mocked(repo.cloneRepo).mockImplementationOnce(async (_remote, dir) => {
158
+ await ensureDir(dir);
159
+ });
160
+
161
+ await editCommand('demo');
162
+
163
+ expect(runYarnInstall).toHaveBeenCalledWith(expect.objectContaining({ cwd: tmp }));
164
+ expect(prepareShadow).toHaveBeenCalledWith(path.join(tmp, 'demo'));
165
+ });
166
+
167
+ it('does not re-wire the workspace on a pull', async () => {
168
+ await makeWorkspace(tmp);
169
+ await ensureDir(path.join(tmp, 'demo'));
170
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
171
+ appName: 'mf-demo',
172
+ version: 3,
173
+ gitRemote: S3_REMOTE,
174
+ entry: {},
175
+ deployedBy: 'u@x.com',
176
+ createdBy: 'u@x.com',
177
+ });
178
+
179
+ await editCommand('demo');
180
+
181
+ expect(runYarnInstall).not.toHaveBeenCalled();
182
+ expect(prepareShadow).not.toHaveBeenCalled();
183
+ });
184
+ });
@@ -4,15 +4,15 @@ import { ensureDir, pathExists, readdir, writeFile, copy } from 'fs-extra';
4
4
  import { info, warn, output, setOutputMode } from '../util/logger';
5
5
  import { assertWorkspace } from '../util/workspace';
6
6
  import { getTemplatesDir, renderTemplate, APP_TEMPLATES, type AppTemplate } from '../util/templates';
7
- import { readManifest } from '../util/manifest';
8
- import { mfAppName } from '../util/app-name';
9
7
  import { APP_NAME_PATTERN } from '../constants';
10
8
  import { runYarnInstall } from '../util/npm/install';
11
- import { initRepo, defaultS3Remote } from '../util/git/repo';
12
- import { buildCommand } from './build';
9
+ import { initRepo } from '../util/git/repo';
10
+ import { REMOTE_TYPES, type RemoteType, validateRemoteOptions, resolveRemoteUrl } from '../util/git/remote-options';
11
+ import { fetchApp } from '../util/broker/app';
12
+ import { mfAppName } from '../util/app-name';
13
+ import { prepareShadow } from '../shadow/prepare';
13
14
 
14
- export const REMOTE_TYPES = ['s3', 'github'] as const;
15
- export type RemoteType = (typeof REMOTE_TYPES)[number];
15
+ export { REMOTE_TYPES, type RemoteType };
16
16
 
17
17
  export interface CreateOptions {
18
18
  template?: string;
@@ -71,15 +71,7 @@ export async function createCommand(appName: string, opts: CreateOptions = {}):
71
71
  }
72
72
 
73
73
  // Validate --remote up front before any file I/O.
74
- if (!opts.remote) {
75
- throw new Error('--remote is required. Use --remote=s3 (shared storage) or --remote=github (your GitHub repo).');
76
- }
77
- if (!(REMOTE_TYPES as readonly string[]).includes(opts.remote)) {
78
- throw new Error(`Unknown remote "${opts.remote}". Use ${REMOTE_TYPES.join(' or ')}.`);
79
- }
80
- if (opts.remote === 'github' && !opts.repoUrl) {
81
- throw new Error('--repo-url is required when using --remote=github.');
82
- }
74
+ validateRemoteOptions(opts, true);
83
75
 
84
76
  // Resolve template.
85
77
  // `--html-file` implies `--template html-embed` unless the user already picked one.
@@ -98,9 +90,9 @@ export async function createCommand(appName: string, opts: CreateOptions = {}):
98
90
  throw new Error(`${appDir} is non-empty. Pick a different name or wipe it first.`);
99
91
  }
100
92
 
101
- // Check for manifest collision (network call — after all local checks).
102
- const manifest = await readManifest();
103
- if (manifest[mfAppName(appName)]) {
93
+ // Check the broker — fail fast if the app is already registered.
94
+ const existing = await fetchApp(mfAppName(appName));
95
+ if (existing) {
104
96
  throw new Error(`An app named "${appName}" already exists in the Z2H registry. Pick a different name.`);
105
97
  }
106
98
 
@@ -134,27 +126,17 @@ export async function createCommand(appName: string, opts: CreateOptions = {}):
134
126
  }
135
127
 
136
128
  info(`initializing git repo for ${appName}...`);
137
- let gitRemote: string;
138
- if (opts.remote === 's3') {
139
- gitRemote = defaultS3Remote(appName);
140
- } else {
141
- gitRemote = opts.repoUrl as string;
142
- }
129
+ const gitRemote = resolveRemoteUrl(opts.remote as RemoteType, appName, opts.repoUrl);
143
130
  await initRepo(appDir, gitRemote);
144
131
 
145
132
  info(`installing dependencies for ${appName}...`);
146
133
  await runYarnInstall({ cwd: workspace });
147
134
 
148
- // build/dev/deploy all locate the consumer via process.cwd() — chdir before calling them.
149
- const prevCwd = process.cwd();
135
+ // Best-effort workspace wiring (.zth shadow). Non-fatal — dev/deploy re-run it.
150
136
  try {
151
- info(`pre-building ${appName}`);
152
- process.chdir(appDir);
153
- await buildCommand();
154
- } catch {
155
- warn(`pre-build failed. No worries — it will run again when needed.`);
156
- } finally {
157
- process.chdir(prevCwd);
137
+ await prepareShadow(appDir);
138
+ } catch (err) {
139
+ warn(`prepare shadow failed: ${(err as Error).message} — it will run again when needed.`);
158
140
  }
159
141
 
160
142
  output(`[z2h-cli] app ready at ${appDir}`, {
@@ -1,27 +1,27 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
+ import { createInterface } from 'node:readline';
3
4
  import { pathExists, readJson } from 'fs-extra';
4
5
  import { info, output, setOutputMode, warn } from '../util/logger';
5
6
  import { resolveConsumerPaths } from '../shadow/paths';
6
- import {
7
- nextVersion,
8
- readManifest,
9
- upsertEntry,
10
- writeManifest,
11
- type AssetManifestFile,
12
- type ManifestEntry,
13
- } from '../util/manifest';
7
+ import { type AssetManifestFile, type ManifestEntry } from '../util/manifest';
14
8
  import { uploadDir } from '../util/s3/upload';
15
- import { BUCKET_WRITE_NAME, BUCKET_READ_DNS_URL, brokerBaseUrl } from '../constants';
16
- import { setupS3Client } from '../util/s3/client';
9
+ import { BUCKET_WRITE_NAME, BUCKET_READ_DNS_URL, brokerBaseUrl, routingHeaders } from '../constants';
10
+ import { brokerCredentialProvider, fetchDeployVersion } from '../util/auth/broker-credential-provider';
17
11
  import { readZthAuth } from '../util/auth/auth-store';
18
12
  import { cleanAppName, mfAppName } from '../util/app-name';
13
+ import { getMachineAppName } from '../util/machine-identity';
14
+ import { getRemoteUrl, guardCleanTree, pullAndResolve, pushToRemote, initRepo } from '../util/git/repo';
15
+ import { resolveGitEnv } from '../util/git/git-env';
16
+ import { type RemoteType, validateRemoteOptions, resolveRemoteUrl } from '../util/git/remote-options';
17
+ import { setupS3Client } from '../util/s3/client';
19
18
  import { buildCommand } from './build';
20
- import { getRemoteUrl } from '../util/git/repo';
21
19
 
22
20
  export interface DeployOptions {
23
21
  dryRun?: boolean;
24
22
  json?: boolean;
23
+ remote?: RemoteType;
24
+ repoUrl?: string;
25
25
  }
26
26
 
27
27
  // Should be run from the consumer app directory — uses process.cwd() to locate it.
@@ -40,12 +40,51 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
40
40
  // Validate the user's deploy token is on disk before doing any expensive
41
41
  // work. Throws Z2hAuthMissingError with a remediation hint if not.
42
42
  const { token } = await readZthAuth();
43
- setupS3Client({ appName });
43
+
44
+ let gitRemote = await getRemoteUrl(process.cwd());
45
+
46
+ if (gitRemote) {
47
+ if (opts.remote || opts.repoUrl) {
48
+ throw new Error(
49
+ `--remote and --repo-url only apply on the first deploy of an app with no git remote.\n` +
50
+ `This app already tracks source at: ${gitRemote}`
51
+ );
52
+ }
53
+ } else {
54
+ if (!opts.remote) {
55
+ throw new Error(
56
+ `This app has no git remote. On first deploy, tell Z2H how to track source:\n\n` +
57
+ ` z2h-cli deploy --remote s3 (default)\n` +
58
+ ` Stores source history in S3 alongside your app. No extra setup.\n\n` +
59
+ ` z2h-cli deploy --remote github --repo-url https://github.com/org/repo.git\n` +
60
+ ` Uses an existing GitHub repo (must exist before deploying).`
61
+ );
62
+ }
63
+ validateRemoteOptions(opts);
64
+ // Pass the raw consumer name (not the mf-prefixed appName) so defaultS3Remote
65
+ // produces the correct s3://bucket/mf-<name>/git path without double prefix.
66
+ const remote = resolveRemoteUrl(opts.remote as RemoteType, consumerPkg.name, opts.repoUrl);
67
+ info(`initializing git repo with ${opts.remote} remote...`);
68
+ await initRepo(process.cwd(), remote);
69
+ gitRemote = remote;
70
+ }
71
+
72
+ const version = await fetchDeployVersion(appName);
73
+ const nextVersion = version + 1;
74
+
75
+ const gitEnv = await resolveGitEnv(appName, gitRemote);
76
+
77
+ // Sync git first so a divergence (GitConflictError) stops us.
78
+ // The skill commits the working tree (with an LLM-authored message) before
79
+ // invoking deploy, so the tree should be clean here.
80
+ if (gitRemote) {
81
+ await guardCleanTree(process.cwd());
82
+ info('syncing with git remote');
83
+ await pullAndResolve(process.cwd(), gitEnv);
84
+ }
44
85
 
45
86
  info('reading current Z2H manifest');
46
- const manifest = await readManifest();
47
- const version = nextVersion(manifest, appName);
48
- const keyPrefix = `${appName}/${version}`;
87
+ const keyPrefix = `${appName}/${nextVersion}`;
49
88
  const url = `${BUCKET_READ_DNS_URL}/${keyPrefix}`;
50
89
 
51
90
  info('building');
@@ -62,12 +101,10 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
62
101
  }
63
102
  const assetManifest = (await readJson(assetManifestPath)) as AssetManifestFile;
64
103
 
65
- const deployedBy = os.userInfo().username;
66
- const gitRemote = await getRemoteUrl(process.cwd());
67
-
104
+ const deployedBy = getMachineAppName() ?? os.userInfo().username;
68
105
  const entry: ManifestEntry = {
69
106
  ...assetManifest,
70
- version,
107
+ version: nextVersion,
71
108
  url,
72
109
  deployedBy,
73
110
  gitRemote,
@@ -87,16 +124,36 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
87
124
  return;
88
125
  }
89
126
 
127
+ // lock → git push → bundle upload → manifest PUT (the manifest write is the single commit point).
90
128
  try {
91
- // Lock is acquired here — uploadDir triggers the STS credential provider on first S3 call
129
+ // Acquire the deploy lock explicitly. Done after the build
130
+ // so the lock hold stays tiny — it wraps only push → upload → manifest.
131
+ // A 409 here means someone else is mid-deploy;
132
+ const creds = await brokerCredentialProvider({ appName })();
133
+ setupS3Client(creds);
134
+ await acquireDeployLock(appName, token);
135
+
136
+ // Push before any S3 write so a rejected push (someone deployed during our
137
+ // build) stops the deploy before the bundle/manifest go live
138
+ if (gitRemote) {
139
+ info('pushing to git remote');
140
+ await pushToRemote(process.cwd(), gitEnv);
141
+ }
142
+
92
143
  info(`uploading build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
93
144
  await uploadDir(paths.shadowBuild, keyPrefix);
94
145
 
95
- info(`updating manifest entry "${appName}" → v${version}`);
96
- await writeManifest(upsertEntry(manifest, appName, entry));
97
-
98
- info(`registering app "${appName}" → v${version}`);
99
- await registerApp(appName, token, version, entry);
146
+ // v1 creates the app row (+ owner grant); later versions release onto it.
147
+ // The two are separate endpoints, so the CLI picks based on the version it
148
+ // was handed.
149
+ if (nextVersion === 1) {
150
+ const visibility = await resolveFirstDeployVisibility(appName, opts.json);
151
+ info(`registering app "${appName}" → v${nextVersion} (${visibility})`);
152
+ await registerApp(appName, token, nextVersion, entry, visibility, gitRemote);
153
+ } else {
154
+ info(`releasing "${appName}" → v${nextVersion}`);
155
+ await updateApp(appName, token, nextVersion, entry);
156
+ }
100
157
  } finally {
101
158
  await releaseLockBestEffort(appName, token);
102
159
  }
@@ -105,10 +162,10 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
105
162
  // before looking it up in the manifest. So the user-facing URL must use
106
163
  // the consumer's de-scoped package name (no `mf-` prefix, no `@scope/`).
107
164
  const liveUrl = `https://bigbrain.me/bigbrain-vibe/${cleanAppName(consumerPkg.name)}`;
108
- output(`[z2h-cli] deployed ✅\n app: ${appName}\n version: ${version}\n open: ${liveUrl}`, {
165
+ output(`[z2h-cli] deployed ✅\n app: ${appName}\n version: ${nextVersion}\n open: ${liveUrl}`, {
109
166
  ok: true,
110
167
  appName,
111
- version,
168
+ version: nextVersion,
112
169
  bucket: BUCKET_WRITE_NAME,
113
170
  keyPrefix,
114
171
  url,
@@ -117,11 +174,18 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
117
174
  });
118
175
  }
119
176
 
120
- async function registerApp(appName: string, token: string, version: number, entry: ManifestEntry): Promise<void> {
177
+ async function registerApp(
178
+ appName: string,
179
+ token: string,
180
+ version: number,
181
+ entry: ManifestEntry,
182
+ visibility: 'public' | 'private',
183
+ gitRemote: string
184
+ ): Promise<void> {
121
185
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/register`, {
122
186
  method: 'POST',
123
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
124
- body: JSON.stringify({ appName, version, entry }),
187
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
188
+ body: JSON.stringify({ appName, version, entry, visibility, gitRemote }),
125
189
  });
126
190
  if (!res.ok) {
127
191
  const text = await res.text();
@@ -129,14 +193,64 @@ async function registerApp(appName: string, token: string, version: number, entr
129
193
  }
130
194
  }
131
195
 
132
- // best-effort — the 5-min TTL backstop handles failures
196
+ // Acquire the per-app deploy lock. Throws a friendly error on 409 (someone else is deploying)
197
+ // so the deploy stops and the caller can retry; the backend TTL backstops a crashed holder.
198
+ async function acquireDeployLock(appName: string, token: string): Promise<void> {
199
+ const res = await fetch(`${brokerBaseUrl()}/z2h-cli/lock/acquire`, {
200
+ method: 'POST',
201
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
202
+ body: JSON.stringify({ appName }),
203
+ });
204
+ if (res.status === 409) {
205
+ throw new Error(`Someone is deploying "${appName}" right now — try again in a moment.`);
206
+ }
207
+ if (!res.ok) {
208
+ const text = await res.text();
209
+ throw new Error(`Failed to acquire deploy lock (${res.status}): ${text.slice(0, 512)}`);
210
+ }
211
+ info(`deploy lock acquired for ${appName}`);
212
+ }
213
+
214
+ async function updateApp(appName: string, token: string, version: number, entry: ManifestEntry): Promise<void> {
215
+ const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
216
+ method: 'POST',
217
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
218
+ body: JSON.stringify({ appName, version, entry }),
219
+ });
220
+ if (!res.ok) {
221
+ const text = await res.text();
222
+ throw new Error(`App update failed (${res.status}): ${text.slice(0, 1024)}`);
223
+ }
224
+ }
225
+
226
+ // First deploy only: decide whether the app is born public or private. The
227
+ // choice is passed to the register endpoint (default private). Non-interactive
228
+ // runs (--json or no TTY) always default to private.
229
+ async function resolveFirstDeployVisibility(appName: string, json?: boolean): Promise<'public' | 'private'> {
230
+ if (json || !process.stdout.isTTY) {
231
+ return 'private';
232
+ }
233
+
234
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
235
+ const answer = await new Promise<string>(resolve => {
236
+ rl.question(`Make "${appName}" public to all monday.com employees? (y/N) `, resolve);
237
+ });
238
+ rl.close();
239
+ return answer.trim().toLowerCase() === 'y' ? 'public' : 'private';
240
+ }
241
+
242
+ // best-effort — the 2-min TTL backstop handles failures
133
243
  async function releaseLockBestEffort(appName: string, token: string): Promise<void> {
134
244
  try {
135
- await fetch(`${brokerBaseUrl()}/z2h-cli/lock/release`, {
245
+ const res = await fetch(`${brokerBaseUrl()}/z2h-cli/lock/release`, {
136
246
  method: 'POST',
137
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
247
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
138
248
  body: JSON.stringify({ appName }),
139
249
  });
250
+ if (!res.ok) {
251
+ warn(`failed to release lock for ${appName} (${res.status})`);
252
+ return;
253
+ }
140
254
  info(`lock released for ${appName}`);
141
255
  } catch {
142
256
  warn(`failed to release lock for ${appName}`);
@@ -0,0 +1,63 @@
1
+ import path from 'node:path';
2
+ import { pathExists } from 'fs-extra';
3
+ import { info, warn, output, setOutputMode } from '../util/logger';
4
+ import { assertWorkspace } from '../util/workspace';
5
+ import { mfAppName } from '../util/app-name';
6
+ import { fetchApp } from '../util/broker/app';
7
+ import { resolveGitEnv } from '../util/git/git-env';
8
+ import { cloneRepo, pullAndResolve } from '../util/git/repo';
9
+ import { runYarnInstall } from '../util/npm/install';
10
+ import { prepareShadow } from '../shadow/prepare';
11
+
12
+ export interface EditOptions {
13
+ json?: boolean;
14
+ }
15
+
16
+ export async function editCommand(appName: string, opts: EditOptions = {}): Promise<void> {
17
+ if (opts.json) {
18
+ setOutputMode('json');
19
+ }
20
+
21
+ const workspace = await assertWorkspace();
22
+ const mfName = mfAppName(appName);
23
+
24
+ const app = await fetchApp(mfName);
25
+ if (!app?.gitRemote) {
26
+ output(`[z2h-cli] ${appName} was deployed without git tracking — run "z2h-cli deploy" to set up a git remote`, {
27
+ ok: true,
28
+ appName,
29
+ });
30
+ return;
31
+ }
32
+ const { gitRemote } = app;
33
+
34
+ const gitEnv = await resolveGitEnv(mfName, gitRemote);
35
+
36
+ const appDir = path.join(workspace, appName);
37
+ const hasLocal = await pathExists(appDir);
38
+
39
+ if (!hasLocal) {
40
+ info(`cloning ${appName}...`);
41
+ await cloneRepo(gitRemote, appDir, gitEnv);
42
+
43
+ info(`installing dependencies for ${appName}...`);
44
+ await runYarnInstall({ cwd: workspace });
45
+
46
+ // Best-effort workspace wiring (.zth shadow). Non-fatal — dev/deploy re-run it.
47
+ try {
48
+ await prepareShadow(appDir);
49
+ } catch (err) {
50
+ warn(`prepare shadow failed: ${(err as Error).message} — it will run again when needed.`);
51
+ }
52
+ } else {
53
+ info(`pulling latest ${appName}...`);
54
+ await pullAndResolve(appDir, gitEnv);
55
+ }
56
+
57
+ output(`[z2h-cli] ${appName} is ready at ${appDir}`, {
58
+ ok: true,
59
+ appDir,
60
+ appName,
61
+ gitRemote,
62
+ });
63
+ }