@mondaydotcomorg/z2h-cli 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) 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 +120 -21
  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 +122 -23
  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/broker-credential-provider.d.ts +10 -2
  43. package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
  44. package/dist/esm/util/auth/broker-credential-provider.mjs +35 -13
  45. package/dist/esm/util/broker/app.d.ts +14 -0
  46. package/dist/esm/util/broker/app.d.ts.map +1 -0
  47. package/dist/esm/util/broker/app.mjs +27 -0
  48. package/dist/esm/util/git/git-env.d.ts +9 -0
  49. package/dist/esm/util/git/git-env.d.ts.map +1 -0
  50. package/dist/esm/util/git/git-env.mjs +22 -0
  51. package/dist/esm/util/git/remote-options.d.ts +17 -0
  52. package/dist/esm/util/git/remote-options.d.ts.map +1 -0
  53. package/dist/esm/util/git/remote-options.mjs +30 -0
  54. package/dist/esm/util/git/repo.d.ts +21 -3
  55. package/dist/esm/util/git/repo.d.ts.map +1 -1
  56. package/dist/esm/util/git/repo.mjs +96 -48
  57. package/dist/esm/util/s3/client.d.ts +2 -7
  58. package/dist/esm/util/s3/client.d.ts.map +1 -1
  59. package/dist/esm/util/s3/client.mjs +3 -11
  60. package/dist/index.js +69 -12
  61. package/dist/shadow/prepare.d.ts +1 -1
  62. package/dist/shadow/prepare.d.ts.map +1 -1
  63. package/dist/shadow/prepare.js +2 -2
  64. package/dist/util/app-name.d.ts +1 -0
  65. package/dist/util/app-name.d.ts.map +1 -1
  66. package/dist/util/app-name.js +11 -0
  67. package/dist/util/auth/broker-credential-provider.d.ts +10 -2
  68. package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
  69. package/dist/util/auth/broker-credential-provider.js +35 -11
  70. package/dist/util/broker/app.d.ts +14 -0
  71. package/dist/util/broker/app.d.ts.map +1 -0
  72. package/dist/util/broker/app.js +29 -0
  73. package/dist/util/git/git-env.d.ts +9 -0
  74. package/dist/util/git/git-env.d.ts.map +1 -0
  75. package/dist/util/git/git-env.js +24 -0
  76. package/dist/util/git/remote-options.d.ts +17 -0
  77. package/dist/util/git/remote-options.d.ts.map +1 -0
  78. package/dist/util/git/remote-options.js +34 -0
  79. package/dist/util/git/repo.d.ts +21 -3
  80. package/dist/util/git/repo.d.ts.map +1 -1
  81. package/dist/util/git/repo.js +97 -48
  82. package/dist/util/s3/client.d.ts +2 -7
  83. package/dist/util/s3/client.d.ts.map +1 -1
  84. package/dist/util/s3/client.js +3 -11
  85. package/package.json +1 -1
  86. package/src/commands/__tests__/create.test.ts +9 -7
  87. package/src/commands/__tests__/edit.test.ts +184 -0
  88. package/src/commands/create.ts +15 -33
  89. package/src/commands/deploy.ts +145 -31
  90. package/src/commands/edit.ts +63 -0
  91. package/src/commands/grant.ts +115 -0
  92. package/src/constants.ts +7 -0
  93. package/src/index.ts +83 -13
  94. package/src/shadow/prepare.ts +2 -2
  95. package/src/templates/app/CLAUDE.md +15 -0
  96. package/src/util/app-name.ts +10 -0
  97. package/src/util/auth/__tests__/broker-credential-provider.test.ts +34 -2
  98. package/src/util/auth/broker-credential-provider.ts +43 -15
  99. package/src/util/broker/app.ts +34 -0
  100. package/src/util/git/__tests__/repo.test.ts +114 -90
  101. package/src/util/git/git-env.ts +23 -0
  102. package/src/util/git/remote-options.ts +37 -0
  103. package/src/util/git/repo.ts +109 -48
  104. package/src/util/s3/client.ts +4 -11
package/src/index.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  import { createRequire } from 'node:module';
2
- import { readFileSync } from 'node:fs';
3
2
  import os from 'node:os';
4
3
  import path from 'node:path';
5
4
  import { Command } from 'commander';
6
5
  import { execa } from 'execa';
6
+ import { getWorkspaceHome } from './util/workspace';
7
7
  import { devCommand } from './commands/dev';
8
8
  import { buildCommand } from './commands/build';
9
9
  import { cleanCommand } from './commands/clean';
10
10
  import { deployCommand } from './commands/deploy';
11
+ import { grantCommand, revokeCommand, transferOwnerCommand } from './commands/grant';
11
12
  import { createWorkspaceCommand } from './commands/create-workspace';
12
13
  import { createCommand, CreateOptions } from './commands/create';
13
14
  import { generateZ2HTokenCommand } from './commands/generate-z2h-token';
14
15
  import { migrateCommand } from './commands/migrate';
16
+ import { editCommand } from './commands/edit';
15
17
  import { errorLog, fail, getOutputMode } from './util/logger';
16
18
  import { trackDuration } from './util/tracker';
19
+ import { getCwdAppName } from './util/app-name';
17
20
 
18
21
  const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
19
22
 
@@ -30,15 +33,17 @@ function selfUpdate(): void {
30
33
  }
31
34
  }
32
35
 
33
- function getCwdAppName(): string | undefined {
34
- try {
35
- return (JSON.parse(readFileSync('package.json', 'utf8')) as { name?: string }).name;
36
- } catch {
37
- return undefined;
36
+ const CONFLICTING_ENV_VARS = ['AWS_PROFILE'];
37
+
38
+ function stripConflictingEnvVars(): void {
39
+ for (const name of CONFLICTING_ENV_VARS) {
40
+ delete process.env[name];
38
41
  }
39
42
  }
40
43
 
41
44
  async function main(): Promise<void> {
45
+ stripConflictingEnvVars();
46
+
42
47
  const program = new Command();
43
48
  program
44
49
  .name('z2h-cli')
@@ -96,14 +101,26 @@ async function main(): Promise<void> {
96
101
 
97
102
  program
98
103
  .command('deploy')
99
- .description('Build, upload artifacts to S3, and update the Z2H manifest')
104
+ .description('Build, upload to S3, and register the app with the Z2H backend')
100
105
  .option('--dry-run', 'print what would happen without uploading anything')
101
106
  .option('--json', 'emit structured JSON on stdout')
102
- .action(async (opts: { dryRun?: boolean; json?: boolean }) => {
107
+ .option(
108
+ '--remote <type>',
109
+ 'First deploy only: source control remote to initialise — s3 (shared storage) or github (your repo). Error if the app already has a git remote.'
110
+ )
111
+ .option(
112
+ '--repo-url <url>',
113
+ 'First deploy only: GitHub repo URL — required when --remote=github. Error if the app already has a git remote.'
114
+ )
115
+ .action(async (opts: { dryRun?: boolean; json?: boolean; remote?: string; repoUrl?: string }) => {
103
116
  try {
104
- await trackDuration('z2h_app_deploy_success', () => deployCommand(opts), {
105
- app_name: getCwdAppName(),
106
- });
117
+ await trackDuration(
118
+ 'z2h_app_deploy_success',
119
+ () => deployCommand(opts as Parameters<typeof deployCommand>[0]),
120
+ {
121
+ app_name: getCwdAppName(),
122
+ }
123
+ );
107
124
  } catch (err) {
108
125
  await fail(`deploy failed: ${(err as Error).message}`, { command: 'deploy' }, 2);
109
126
  }
@@ -111,7 +128,7 @@ async function main(): Promise<void> {
111
128
 
112
129
  program
113
130
  .command('create-workspace')
114
- .description('Bootstrap the ~/zth-projects/ yarn workspace (one-time setup)')
131
+ .description(`Bootstrap the ${getWorkspaceHome()}/ yarn workspace (one-time setup)`)
115
132
  .option('--json', 'emit structured JSON on stdout')
116
133
  .action(async (opts: { json?: boolean }) => {
117
134
  try {
@@ -139,7 +156,7 @@ async function main(): Promise<void> {
139
156
 
140
157
  program
141
158
  .command('create <app-name>')
142
- .description('Scaffold a new Z2H consumer app inside ~/zth-projects/')
159
+ .description(`Scaffold a new Z2H consumer app inside ${getWorkspaceHome()}/`)
143
160
  .option('--template <name>', 'Template name (default | html-embed)')
144
161
  .option('--html-file <path>', 'Path to an HTML file to embed (implies --template html-embed)')
145
162
  .option('--remote <type>', 'Source control remote: s3 (shared storage) or github (your repo)')
@@ -162,6 +179,59 @@ async function main(): Promise<void> {
162
179
  }
163
180
  );
164
181
 
182
+ program
183
+ .command('edit <app-name>')
184
+ .description(`Pull the latest version of an app into ${getWorkspaceHome()}/<app-name>, cloning it first if needed`)
185
+ .option('--json', 'emit structured JSON on stdout')
186
+ .action(async (appName: string, opts: { json?: boolean }) => {
187
+ try {
188
+ await trackDuration('z2h_app_edit_pulled', () => editCommand(appName, opts), {
189
+ app_name: appName,
190
+ });
191
+ } catch (err) {
192
+ await fail(`edit failed: ${(err as Error).message}`, { command: 'edit' }, 1);
193
+ }
194
+ });
195
+
196
+ program
197
+ .command('grant [email]')
198
+ .description('Grant access to the current app (email, --public, or both)')
199
+ .option('--public', 'make the app visible to all monday.com employees')
200
+ .option('--level <level>', 'access level for user grants: owner, editor, or viewer (default: viewer)')
201
+ .option('--json', 'emit structured JSON on stdout')
202
+ .action(async (email: string | undefined, opts: { public?: boolean; level?: string; json?: boolean }) => {
203
+ try {
204
+ await grantCommand(email, opts as Parameters<typeof grantCommand>[1]);
205
+ } catch (err) {
206
+ await fail(`grant failed: ${(err as Error).message}`, { command: 'grant' }, 1);
207
+ }
208
+ });
209
+
210
+ program
211
+ .command('revoke [email]')
212
+ .description('Revoke access to the current app (email or --public; owners/admins only)')
213
+ .option('--public', 'remove the public grant, making the app visible to owners/admins only')
214
+ .option('--json', 'emit structured JSON on stdout')
215
+ .action(async (email: string | undefined, opts: { public?: boolean; json?: boolean }) => {
216
+ try {
217
+ await revokeCommand(email, opts as Parameters<typeof revokeCommand>[1]);
218
+ } catch (err) {
219
+ await fail(`revoke failed: ${(err as Error).message}`, { command: 'revoke' }, 1);
220
+ }
221
+ });
222
+
223
+ program
224
+ .command('transfer-owner [email]')
225
+ .description('Transfer ownership of the current app to another monday.com user')
226
+ .option('--json', 'emit structured JSON on stdout')
227
+ .action(async (email: string | undefined, opts: { json?: boolean }) => {
228
+ try {
229
+ await transferOwnerCommand(email, opts);
230
+ } catch (err) {
231
+ await fail(`transfer-owner failed: ${(err as Error).message}`, { command: 'transfer-owner' }, 1);
232
+ }
233
+ });
234
+
165
235
  program
166
236
  .command('migrate')
167
237
  .description('Run pending workspace migrations')
@@ -10,8 +10,8 @@ import { isPortFree, pickPort } from './port';
10
10
  import { syncShadow } from './sync';
11
11
  import type { ConsumerPaths, ConsumerPkg, PreparedShadow, ShadowMetadata } from '../types';
12
12
 
13
- export async function prepareShadow(): Promise<PreparedShadow> {
14
- const paths = resolveConsumerPaths(process.cwd());
13
+ export async function prepareShadow(consumerDir: string = process.cwd()): Promise<PreparedShadow> {
14
+ const paths = resolveConsumerPaths(consumerDir);
15
15
 
16
16
  if (!(await pathExists(paths.consumerPkg))) {
17
17
  throw new Error(`No package.json found in ${paths.consumerDir}`);
@@ -45,6 +45,21 @@ When a request needs data, follow `z2h:coding-standards-mf` §0: write a `runQue
45
45
 
46
46
  ---
47
47
 
48
+ ## App Visibility
49
+
50
+ Apps are **private by default** — only you can view them. After the first deploy the `z2h:feature-development` skill automatically asks whether to open it up:
51
+
52
+ > "Should this app be visible to everyone at monday.com, or just you for now?"
53
+
54
+ To change visibility at any time:
55
+
56
+ ```
57
+ npx z2h-cli grant --public # open to all monday.com employees
58
+ npx z2h-cli revoke --public # back to private
59
+ ```
60
+
61
+ ---
62
+
48
63
  ## ⛔ No Agent Visual Testing
49
64
 
50
65
  When you make a change, **do not screenshot, do not run `axcli chrome`, do not run `playwright-cli`, do not open browser tabs**. The user is sitting in front of the live preview and verifies with their own eyes — HMR updates instantly. Edit → tell the user → wait for their approval. Agent-side visual checks have proven slow and unreliable in this flow.
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+
1
3
  /**
2
4
  * Strip any npm scope (`@scope/`) from a package name.
3
5
  * e.g. `@trident/customer-viewer` → `customer-viewer`, `foo` → `foo`.
@@ -6,6 +8,14 @@ export function cleanAppName(name: string): string {
6
8
  return name.replace(/^@[^/]+\//, '');
7
9
  }
8
10
 
11
+ export function getCwdAppName(): string | undefined {
12
+ try {
13
+ return (JSON.parse(readFileSync('package.json', 'utf8')) as { name?: string }).name;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
9
19
  /**
10
20
  * Canonical MF bundle name: the de-scoped app name with an `mf-` prefix.
11
21
  * This matches the trident convention (shadow pkg is `@trident/mf-<app>`,
@@ -1,5 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { brokerCredentialProvider, STS_BROKER_PATH } from '../broker-credential-provider';
2
+ import {
3
+ brokerCredentialProvider,
4
+ fetchDeployVersion,
5
+ DEPLOY_VERSION_PATH,
6
+ STS_BROKER_PATH,
7
+ } from '../broker-credential-provider';
3
8
  import { DEFAULT_BROKER_BASE_URL } from '../../../constants';
4
9
  import * as authStore from '../auth-store';
5
10
 
@@ -32,8 +37,35 @@ function mockFetchOnce(body: unknown, init: { status?: number; text?: string } =
32
37
  return fetchMock;
33
38
  }
34
39
 
40
+ describe('fetchDeployVersion', () => {
41
+ it('POSTs the appName to the version endpoint and returns the version', async () => {
42
+ const fetchMock = mockFetchOnce({ version: 4 });
43
+
44
+ const version = await fetchDeployVersion('my-app');
45
+
46
+ expect(version).toBe(4);
47
+ expect(fetchMock).toHaveBeenCalledWith(
48
+ `${DEFAULT_BROKER_BASE_URL}${DEPLOY_VERSION_PATH}`,
49
+ expect.objectContaining({
50
+ method: 'POST',
51
+ body: JSON.stringify({ appName: 'my-app' }),
52
+ })
53
+ );
54
+ });
55
+
56
+ it('returns 0 when the broker returns no version field (new app)', async () => {
57
+ mockFetchOnce({});
58
+ await expect(fetchDeployVersion('my-app')).resolves.toBe(0);
59
+ });
60
+
61
+ it('surfaces a remediation hint on 401', async () => {
62
+ mockFetchOnce({}, { status: 401, text: 'Unauthorized' });
63
+ await expect(fetchDeployVersion('my-app')).rejects.toThrow(/token rejected by the broker/);
64
+ });
65
+ });
66
+
35
67
  describe('brokerCredentialProvider', () => {
36
- it('POSTs the appName to the broker with a Bearer token + parses the response', async () => {
68
+ it('POSTs the appName + version to the broker with a Bearer token + parses the response', async () => {
37
69
  const fetchMock = mockFetchOnce({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY });
38
70
  const provider = brokerCredentialProvider({ appName: 'my-app' });
39
71
  const creds = await provider();
@@ -1,51 +1,78 @@
1
- import { brokerBaseUrl } from '../../constants';
1
+ import { brokerBaseUrl, routingHeaders } from '../../constants';
2
2
  import { readZthAuth } from './auth-store';
3
3
  import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@aws-sdk/types';
4
4
 
5
5
  export const STS_BROKER_PATH = '/z2h-cli/sts/credentials';
6
+ export const DEPLOY_VERSION_PATH = '/z2h-cli/apps/version';
6
7
 
7
8
  // Refresh the cached credentials this many ms before they expire so the next
8
9
  // AWS API call doesn't race the broker round-trip against a hard expiry.
9
10
  const REFRESH_BUFFER_MS = 5 * 60 * 1000;
10
11
 
11
- interface BrokerResponse {
12
+ interface CredentialsResponse {
12
13
  accessKeyId?: string;
13
14
  secretAccessKey?: string;
14
15
  sessionToken?: string;
15
16
  expiration?: string;
16
17
  }
17
18
 
18
- async function fetchCredentialsOnce(appName: string): Promise<AwsCredentialIdentity> {
19
- const { token } = await readZthAuth();
20
- const url = `${brokerBaseUrl()}${STS_BROKER_PATH}`;
19
+ interface VersionResponse {
20
+ version?: number;
21
+ }
21
22
 
22
- let res: Response;
23
+ async function postToBroker(path: string, body: unknown): Promise<Response> {
24
+ const { token } = await readZthAuth();
23
25
  try {
24
- res = await fetch(url, {
26
+ return await fetch(`${brokerBaseUrl()}${path}`, {
25
27
  method: 'POST',
26
28
  headers: {
27
29
  Authorization: `Bearer ${token}`,
28
30
  'Content-Type': 'application/json',
29
31
  Accept: 'application/json',
32
+ ...routingHeaders(),
30
33
  },
31
- body: JSON.stringify({ appName }),
34
+ body: JSON.stringify(body),
32
35
  });
33
36
  } catch (err) {
34
37
  const message = err instanceof Error ? err.message : String(err);
35
38
  throw new Error(`Z2H deploy broker request failed: ${message}`);
36
39
  }
40
+ }
37
41
 
42
+ function assertBrokerOk(res: Response): void {
38
43
  if (res.status === 401 || res.status === 403) {
39
44
  throw new Error(
40
45
  `Z2H deploy token rejected by the broker (${res.status}). Refresh user token by running 'z2h-cli generate-z2h-token'.`
41
46
  );
42
47
  }
48
+ }
49
+
50
+ /**
51
+ * Reserve the version this deploy will write to. Split out of the STS
52
+ * credentials call so version derivation lives on its own endpoint — the CLI
53
+ * asks for the version first, then requests credentials scoped to it.
54
+ */
55
+ export async function fetchDeployVersion(appName: string): Promise<number> {
56
+ const res = await postToBroker(DEPLOY_VERSION_PATH, { appName });
57
+ assertBrokerOk(res);
58
+ if (!res.ok) {
59
+ const text = await res.text();
60
+ throw new Error(`Z2H deploy broker returned ${res.status}: ${text.slice(0, 1024)}`);
61
+ }
62
+ const body = (await res.json()) as VersionResponse;
63
+
64
+ return body.version ?? 0;
65
+ }
66
+
67
+ async function fetchStsOnce(appName: string): Promise<AwsCredentialIdentity> {
68
+ const res = await postToBroker(STS_BROKER_PATH, { appName });
69
+ assertBrokerOk(res);
43
70
  if (!res.ok) {
44
71
  const text = await res.text();
45
72
  throw new Error(`Z2H deploy broker returned ${res.status}: ${text.slice(0, 1024)}`);
46
73
  }
47
74
 
48
- const body = (await res.json()) as BrokerResponse;
75
+ const body = (await res.json()) as CredentialsResponse;
49
76
  if (!body.accessKeyId || !body.secretAccessKey || !body.sessionToken || !body.expiration) {
50
77
  throw new Error('Z2H deploy broker response missing required credential fields');
51
78
  }
@@ -60,8 +87,9 @@ async function fetchCredentialsOnce(appName: string): Promise<AwsCredentialIdent
60
87
 
61
88
  /**
62
89
  * AWS SDK credential provider that fetches short-lived deploy credentials
63
- * from the bigbrain-zth STS broker. Credentials are cached in memory until
64
- * 5 minutes before expiry; concurrent calls share a single in-flight fetch.
90
+ * from the bigbrain-zth STS broker for a specific version. Credentials are
91
+ * cached in memory until 5 minutes before expiry; concurrent calls share a
92
+ * single in-flight fetch.
65
93
  */
66
94
  export function brokerCredentialProvider({ appName }: { appName: string }): AwsCredentialIdentityProvider {
67
95
  let cached: AwsCredentialIdentity | undefined;
@@ -76,10 +104,10 @@ export function brokerCredentialProvider({ appName }: { appName: string }): AwsC
76
104
  return inflight;
77
105
  }
78
106
 
79
- inflight = fetchCredentialsOnce(appName)
80
- .then(creds => {
81
- cached = creds;
82
- return creds;
107
+ inflight = fetchStsOnce(appName)
108
+ .then(credentials => {
109
+ cached = credentials;
110
+ return credentials;
83
111
  })
84
112
  .finally(() => {
85
113
  inflight = undefined;
@@ -0,0 +1,34 @@
1
+ import { brokerBaseUrl, routingHeaders } from '../../constants';
2
+ import { readZthAuth } from '../auth/auth-store';
3
+
4
+ export interface AppInfo {
5
+ appName: string;
6
+ version: number;
7
+ entry: Record<string, unknown>;
8
+ deployedBy: string;
9
+ gitRemote: string;
10
+ createdBy: string;
11
+ }
12
+
13
+ /**
14
+ * Fetches a single app's current state from the broker.
15
+ * Returns null if the app has never been deployed (404).
16
+ */
17
+ export async function fetchApp(appName: string): Promise<AppInfo | null> {
18
+ const { token } = await readZthAuth();
19
+ const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/${encodeURIComponent(appName)}`, {
20
+ headers: {
21
+ Authorization: `Bearer ${token}`,
22
+ Accept: 'application/json',
23
+ ...routingHeaders(),
24
+ },
25
+ });
26
+ if (res.status === 404) {
27
+ return null;
28
+ }
29
+ if (!res.ok) {
30
+ const text = await res.text();
31
+ throw new Error(`Z2H app lookup failed (${res.status}): ${text.slice(0, 512)}`);
32
+ }
33
+ return res.json() as Promise<AppInfo>;
34
+ }
@@ -8,11 +8,12 @@ import {
8
8
  defaultS3Remote,
9
9
  initRepo,
10
10
  cloneRepo,
11
+ guardCleanTree,
11
12
  safeBackupIfDirty,
12
13
  pullAndResolve,
14
+ pushToRemote,
13
15
  remoteHead,
14
16
  localHead,
15
- commitDetailedAndPush,
16
17
  } from '../repo';
17
18
 
18
19
  vi.mock('execa', () => ({ execa: vi.fn() }));
@@ -22,6 +23,7 @@ vi.mock('fs-extra', () => ({
22
23
  readFile: vi.fn(),
23
24
  outputFile: vi.fn(),
24
25
  pathExists: vi.fn(),
26
+ ensureDir: vi.fn().mockResolvedValue(undefined),
25
27
  },
26
28
  }));
27
29
 
@@ -50,9 +52,59 @@ describe('defaultS3Remote', () => {
50
52
 
51
53
  describe('cloneRepo', () => {
52
54
  it('calls git clone with correct args', async () => {
53
- mockExeca.mockResolvedValueOnce(execaOk());
55
+ mockExeca.mockResolvedValueOnce(execaOk()); // git clone
56
+ mockExeca.mockResolvedValueOnce(execaOk()); // git config --local core.hooksPath
54
57
  await cloneRepo('s3://bucket/my-app/git', DIR);
55
- expect(mockExeca).toHaveBeenCalledWith('git', ['clone', 's3://bucket/my-app/git', DIR]);
58
+ expect(mockExeca).toHaveBeenCalledWith(
59
+ 'git',
60
+ ['clone', 's3://bucket/my-app/git', DIR],
61
+ expect.objectContaining({})
62
+ );
63
+ });
64
+
65
+ it('merges supplied env into process.env for git subprocess', async () => {
66
+ mockExeca.mockResolvedValueOnce(execaOk()); // git clone
67
+ mockExeca.mockResolvedValueOnce(execaOk()); // git config --local core.hooksPath
68
+ await cloneRepo('s3://bucket/my-app/git', DIR, { AWS_ACCESS_KEY_ID: 'KEY' });
69
+ const opts = (mockExeca.mock.calls[0] as unknown as unknown[])[2] as { env: Record<string, string> };
70
+ expect(opts.env['AWS_ACCESS_KEY_ID']).toBe('KEY');
71
+ });
72
+
73
+ it('disables hooks locally after clone', async () => {
74
+ mockExeca.mockResolvedValueOnce(execaOk()); // git clone
75
+ mockExeca.mockResolvedValueOnce(execaOk()); // git config --local core.hooksPath
76
+ await cloneRepo('s3://bucket/my-app/git', DIR);
77
+ expect(mockExeca).toHaveBeenCalledWith(
78
+ 'git',
79
+ ['config', '--local', 'core.hooksPath', '.zth/hooks'],
80
+ expect.objectContaining({})
81
+ );
82
+ });
83
+
84
+ it('skips disabling hooks when .zth/hooks already exists', async () => {
85
+ mockExeca.mockResolvedValueOnce(execaOk()); // git clone
86
+ mockFs.pathExists.mockResolvedValueOnce(true as never); // .zth/hooks exists
87
+ await cloneRepo('s3://bucket/my-app/git', DIR);
88
+ expect(mockExeca).toHaveBeenCalledTimes(1); // only the clone call
89
+ });
90
+ });
91
+
92
+ // ── guardCleanTree ───────────────────────────────────────────────────────────
93
+
94
+ describe('guardCleanTree', () => {
95
+ it('resolves when working tree is clean', async () => {
96
+ mockExeca.mockResolvedValueOnce(execaOk(''));
97
+ await expect(guardCleanTree(DIR)).resolves.toBeUndefined();
98
+ });
99
+
100
+ it('throws when working tree has uncommitted changes', async () => {
101
+ mockExeca.mockResolvedValueOnce(execaOk('M src/index.ts'));
102
+ await expect(guardCleanTree(DIR)).rejects.toThrow('uncommitted changes');
103
+ });
104
+
105
+ it('propagates the error when git status fails (e.g. not a git repo)', async () => {
106
+ mockExeca.mockRejectedValueOnce(new Error('fatal: not a git repository (or any of the parent directories): .git'));
107
+ await expect(guardCleanTree(DIR)).rejects.toThrow(/not a git repository/);
56
108
  });
57
109
  });
58
110
 
@@ -132,10 +184,66 @@ describe('pullAndResolve', () => {
132
184
  await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
133
185
  });
134
186
 
135
- it('throws GitConflictError on non-fast-forward', async () => {
136
- mockExeca.mockRejectedValueOnce(new Error('fatal: Not possible to fast-forward')); // git pull
187
+ it('throws GitConflictError with structured JSON on non-fast-forward', async () => {
188
+ mockExeca
189
+ .mockRejectedValueOnce(new Error('fatal: Not possible to fast-forward')) // git pull
190
+ .mockResolvedValueOnce(execaOk('')) // safeBackupIfDirty: git status --porcelain (clean)
191
+ .mockResolvedValueOnce(execaOk()) // gatherConflictInfo: git fetch origin
192
+ .mockResolvedValueOnce(execaOk('abc local commit')) // git log origin/master..HEAD
193
+ .mockResolvedValueOnce(execaOk('def remote commit')) // git log HEAD..origin/master
194
+ .mockResolvedValueOnce(execaOk('src/index.ts')); // git diff --name-only
195
+
196
+ const err = await pullAndResolve(DIR).catch(e => e);
197
+
198
+ expect(err).toBeInstanceOf(GitConflictError);
199
+ const info = JSON.parse(err.message);
200
+ expect(info.localCommits).toEqual(['abc local commit']);
201
+ expect(info.remoteCommits).toEqual(['def remote commit']);
202
+ expect(info.conflictingFiles).toEqual(['src/index.ts']);
203
+ });
204
+ });
205
+
206
+ // ── pushToRemote ─────────────────────────────────────────────────────────────
207
+
208
+ describe('pushToRemote', () => {
209
+ it('pushes to origin master', async () => {
210
+ mockExeca.mockResolvedValueOnce(execaOk()); // git push -u origin master
211
+
212
+ await expect(pushToRemote(DIR)).resolves.toBeUndefined();
213
+
214
+ expect(mockExeca).toHaveBeenCalledWith('git', ['push', '-u', 'origin', 'master'], expect.objectContaining({}));
215
+ });
216
+
217
+ it('throws GitConflictError with structured JSON when push is rejected', async () => {
218
+ mockExeca
219
+ .mockRejectedValueOnce(new Error('![rejected] master -> master (non-fast-forward)')) // git push
220
+ .mockResolvedValueOnce(execaOk()) // gatherConflictInfo: git fetch origin
221
+ .mockResolvedValueOnce(execaOk('abc local')) // git log origin/master..HEAD
222
+ .mockResolvedValueOnce(execaOk('def remote')) // git log HEAD..origin/master
223
+ .mockResolvedValueOnce(execaOk('file.ts')); // git diff --name-only
224
+
225
+ const err = await pushToRemote(DIR).catch(e => e);
226
+
227
+ expect(err).toBeInstanceOf(GitConflictError);
228
+ const info = JSON.parse(err.message);
229
+ expect(info.localCommits).toEqual(['abc local']);
230
+ expect(info.remoteCommits).toEqual(['def remote']);
231
+ expect(info.conflictingFiles).toEqual(['file.ts']);
232
+ });
233
+
234
+ it('re-throws non-rejection push errors as-is', async () => {
235
+ mockExeca.mockRejectedValueOnce(new Error('Connection refused'));
236
+
237
+ const err = await pushToRemote(DIR).catch(e => e);
238
+ expect(err).not.toBeInstanceOf(GitConflictError);
239
+ expect(err.message).toMatch(/Connection refused/);
240
+ });
137
241
 
138
- await expect(pullAndResolve(DIR)).rejects.toThrow(GitConflictError);
242
+ it('passes env to git subprocess', async () => {
243
+ mockExeca.mockResolvedValueOnce(execaOk());
244
+ await pushToRemote(DIR, { AWS_ACCESS_KEY_ID: 'KEY' });
245
+ const opts = (mockExeca.mock.calls[0] as unknown as unknown[])[2] as { env: Record<string, string> };
246
+ expect(opts.env['AWS_ACCESS_KEY_ID']).toBe('KEY');
139
247
  });
140
248
  });
141
249
 
@@ -161,87 +269,3 @@ describe('localHead', () => {
161
269
  expect(await localHead(DIR)).toBe('abc123');
162
270
  });
163
271
  });
164
-
165
- // ── commitDetailedAndPush ────────────────────────────────────────────────────
166
-
167
- describe('commitDetailedAndPush', () => {
168
- it('throws when not on master branch', async () => {
169
- mockExeca.mockResolvedValueOnce(execaOk('feature/oops\n')); // git rev-parse --abbrev-ref HEAD
170
-
171
- await expect(commitDetailedAndPush(DIR, 1)).rejects.toThrow(/expected branch "master"/);
172
- });
173
-
174
- it('commits with a version-prefixed message and pushes', async () => {
175
- mockExeca
176
- .mockResolvedValueOnce(execaOk('master\n')) // git rev-parse --abbrev-ref HEAD
177
- .mockResolvedValueOnce(execaOk()) // git add .
178
- .mockResolvedValueOnce(execaOk('src/index.ts\nnew-file.ts')) // git diff --cached --name-only
179
- .mockResolvedValueOnce(execaOk('M src/index.ts\nA new-file.ts')) // git status --short
180
- .mockResolvedValueOnce(execaOk()) // git commit
181
- .mockResolvedValueOnce(execaOk()); // git push -u origin master
182
-
183
- await commitDetailedAndPush(DIR, 3);
184
-
185
- const commitCall = mockExeca.mock.calls.find(c => c[0] === 'git' && (c[1] as string[])[0] === 'commit');
186
- expect(commitCall).toBeDefined();
187
- const msg = (commitCall![1] as string[])[2];
188
- expect(msg).toMatch(/^v3:/);
189
- expect(msg).toContain('src/index.ts');
190
-
191
- const pushCall = mockExeca.mock.calls.find(c => c[0] === 'git' && (c[1] as string[])[0] === 'push');
192
- expect(pushCall![1]).toEqual(['push', '-u', 'origin', 'master']);
193
- });
194
-
195
- it('throws when there is nothing to commit', async () => {
196
- mockExeca
197
- .mockResolvedValueOnce(execaOk('master\n')) // git rev-parse --abbrev-ref HEAD
198
- .mockResolvedValueOnce(execaOk()) // git add .
199
- .mockResolvedValueOnce(execaOk('')); // git diff --cached --name-only (empty)
200
-
201
- await expect(commitDetailedAndPush(DIR, 4)).rejects.toThrow(/Nothing new to deploy/);
202
- });
203
-
204
- it('throws GitConflictError when push is rejected', async () => {
205
- mockExeca
206
- .mockResolvedValueOnce(execaOk('master\n')) // git rev-parse --abbrev-ref HEAD
207
- .mockResolvedValueOnce(execaOk()) // git add .
208
- .mockResolvedValueOnce(execaOk('src/index.ts')) // git diff --cached --name-only
209
- .mockResolvedValueOnce(execaOk(' M src/index.ts')) // git status --short
210
- .mockResolvedValueOnce(execaOk()) // git commit
211
- .mockRejectedValueOnce(new Error('[rejected]')); // git push -u origin master
212
-
213
- await expect(commitDetailedAndPush(DIR, 4)).rejects.toThrow(GitConflictError);
214
- });
215
-
216
- it('re-throws non-rejection push errors as-is', async () => {
217
- mockExeca
218
- .mockResolvedValueOnce(execaOk('master\n')) // git rev-parse --abbrev-ref HEAD
219
- .mockResolvedValueOnce(execaOk()) // git add .
220
- .mockResolvedValueOnce(execaOk('src/index.ts')) // git diff --cached --name-only
221
- .mockResolvedValueOnce(execaOk(' M src/index.ts')) // git status --short
222
- .mockResolvedValueOnce(execaOk()) // git commit
223
- .mockRejectedValueOnce(new Error('Connection refused')); // git push — auth/network error
224
-
225
- const err = await commitDetailedAndPush(DIR, 4).catch(e => e);
226
- expect(err).not.toBeInstanceOf(GitConflictError);
227
- expect(err.message).toMatch(/Connection refused/);
228
- });
229
-
230
- it('truncates long file lists to 5 entries', async () => {
231
- const manyFiles = Array.from({ length: 8 }, (_, i) => `M file${i}.ts`).join('\n');
232
- const manyNames = Array.from({ length: 8 }, (_, i) => `file${i}.ts`).join('\n');
233
- mockExeca
234
- .mockResolvedValueOnce(execaOk('master\n')) // git rev-parse --abbrev-ref HEAD
235
- .mockResolvedValueOnce(execaOk()) // git add .
236
- .mockResolvedValueOnce(execaOk(manyNames)) // git diff --cached --name-only
237
- .mockResolvedValueOnce(execaOk(manyFiles)) // git status --short
238
- .mockResolvedValueOnce(execaOk()) // git commit
239
- .mockResolvedValueOnce(execaOk()); // git push
240
-
241
- await commitDetailedAndPush(DIR, 1);
242
-
243
- const commitCall = mockExeca.mock.calls.find(c => c[0] === 'git' && (c[1] as string[])[0] === 'commit');
244
- const msg = (commitCall![1] as string[])[2];
245
- expect(msg).toContain('+3 more');
246
- });
247
- });
@@ -0,0 +1,23 @@
1
+ import { brokerCredentialProvider } from '../auth/broker-credential-provider';
2
+
3
+ /**
4
+ * Builds the AWS_* environment that `git-remote-s3` needs to talk to an S3-backed
5
+ * git remote, by minting short-lived broker credentials scoped to the app.
6
+ *
7
+ * Returns `undefined` for non-S3 remotes (GitHub apps ride the user's own git
8
+ * credential helper) and when there is no remote at all.
9
+ */
10
+ export async function resolveGitEnv(
11
+ appName: string,
12
+ gitRemote: string | undefined
13
+ ): Promise<Record<string, string> | undefined> {
14
+ if (!gitRemote?.startsWith('s3://')) {
15
+ return undefined;
16
+ }
17
+ const creds = await brokerCredentialProvider({ appName })();
18
+ return {
19
+ AWS_ACCESS_KEY_ID: creds.accessKeyId,
20
+ AWS_SECRET_ACCESS_KEY: creds.secretAccessKey,
21
+ AWS_SESSION_TOKEN: creds.sessionToken ?? '',
22
+ };
23
+ }