@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
@@ -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,53 @@ 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.mockRejectedValueOnce(new Error('no global hooksPath')); // git config --global (no override needed)
57
+ await cloneRepo('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.mockRejectedValueOnce(new Error('no global hooksPath')); // git config --global
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('overrides global hooks locally when core.hooksPath is set', async () => {
74
+ mockExeca.mockResolvedValueOnce(execaOk()); // git clone
75
+ mockExeca.mockResolvedValueOnce(execaOk('/Users/dev/.global-git-hooks')); // git config --global core.hooksPath
76
+ mockExeca.mockResolvedValueOnce(execaOk()); // git config --local core.hooksPath
54
77
  await cloneRepo('s3://bucket/my-app/git', DIR);
55
- expect(mockExeca).toHaveBeenCalledWith('git', ['clone', 's3://bucket/my-app/git', DIR]);
78
+ expect(mockExeca).toHaveBeenCalledWith(
79
+ 'git',
80
+ ['config', '--local', 'core.hooksPath', '.zth/hooks'],
81
+ expect.objectContaining({})
82
+ );
83
+ });
84
+ });
85
+
86
+ // ── guardCleanTree ───────────────────────────────────────────────────────────
87
+
88
+ describe('guardCleanTree', () => {
89
+ it('resolves when working tree is clean', async () => {
90
+ mockExeca.mockResolvedValueOnce(execaOk(''));
91
+ await expect(guardCleanTree(DIR)).resolves.toBeUndefined();
92
+ });
93
+
94
+ it('throws when working tree has uncommitted changes', async () => {
95
+ mockExeca.mockResolvedValueOnce(execaOk('M src/index.ts'));
96
+ await expect(guardCleanTree(DIR)).rejects.toThrow('uncommitted changes');
97
+ });
98
+
99
+ it('propagates the error when git status fails (e.g. not a git repo)', async () => {
100
+ mockExeca.mockRejectedValueOnce(new Error('fatal: not a git repository (or any of the parent directories): .git'));
101
+ await expect(guardCleanTree(DIR)).rejects.toThrow(/not a git repository/);
56
102
  });
57
103
  });
58
104
 
@@ -132,10 +178,66 @@ describe('pullAndResolve', () => {
132
178
  await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
133
179
  });
134
180
 
135
- it('throws GitConflictError on non-fast-forward', async () => {
136
- mockExeca.mockRejectedValueOnce(new Error('fatal: Not possible to fast-forward')); // git pull
181
+ it('throws GitConflictError with structured JSON on non-fast-forward', async () => {
182
+ mockExeca
183
+ .mockRejectedValueOnce(new Error('fatal: Not possible to fast-forward')) // git pull
184
+ .mockResolvedValueOnce(execaOk('')) // safeBackupIfDirty: git status --porcelain (clean)
185
+ .mockResolvedValueOnce(execaOk()) // gatherConflictInfo: git fetch origin
186
+ .mockResolvedValueOnce(execaOk('abc local commit')) // git log origin/master..HEAD
187
+ .mockResolvedValueOnce(execaOk('def remote commit')) // git log HEAD..origin/master
188
+ .mockResolvedValueOnce(execaOk('src/index.ts')); // git diff --name-only
189
+
190
+ const err = await pullAndResolve(DIR).catch(e => e);
191
+
192
+ expect(err).toBeInstanceOf(GitConflictError);
193
+ const info = JSON.parse(err.message);
194
+ expect(info.localCommits).toEqual(['abc local commit']);
195
+ expect(info.remoteCommits).toEqual(['def remote commit']);
196
+ expect(info.conflictingFiles).toEqual(['src/index.ts']);
197
+ });
198
+ });
199
+
200
+ // ── pushToRemote ─────────────────────────────────────────────────────────────
201
+
202
+ describe('pushToRemote', () => {
203
+ it('pushes to origin master', async () => {
204
+ mockExeca.mockResolvedValueOnce(execaOk()); // git push -u origin master
205
+
206
+ await expect(pushToRemote(DIR)).resolves.toBeUndefined();
207
+
208
+ expect(mockExeca).toHaveBeenCalledWith('git', ['push', '-u', 'origin', 'master'], expect.objectContaining({}));
209
+ });
210
+
211
+ it('throws GitConflictError with structured JSON when push is rejected', async () => {
212
+ mockExeca
213
+ .mockRejectedValueOnce(new Error('![rejected] master -> master (non-fast-forward)')) // git push
214
+ .mockResolvedValueOnce(execaOk()) // gatherConflictInfo: git fetch origin
215
+ .mockResolvedValueOnce(execaOk('abc local')) // git log origin/master..HEAD
216
+ .mockResolvedValueOnce(execaOk('def remote')) // git log HEAD..origin/master
217
+ .mockResolvedValueOnce(execaOk('file.ts')); // git diff --name-only
218
+
219
+ const err = await pushToRemote(DIR).catch(e => e);
220
+
221
+ expect(err).toBeInstanceOf(GitConflictError);
222
+ const info = JSON.parse(err.message);
223
+ expect(info.localCommits).toEqual(['abc local']);
224
+ expect(info.remoteCommits).toEqual(['def remote']);
225
+ expect(info.conflictingFiles).toEqual(['file.ts']);
226
+ });
227
+
228
+ it('re-throws non-rejection push errors as-is', async () => {
229
+ mockExeca.mockRejectedValueOnce(new Error('Connection refused'));
137
230
 
138
- await expect(pullAndResolve(DIR)).rejects.toThrow(GitConflictError);
231
+ const err = await pushToRemote(DIR).catch(e => e);
232
+ expect(err).not.toBeInstanceOf(GitConflictError);
233
+ expect(err.message).toMatch(/Connection refused/);
234
+ });
235
+
236
+ it('passes env to git subprocess', async () => {
237
+ mockExeca.mockResolvedValueOnce(execaOk());
238
+ await pushToRemote(DIR, { AWS_ACCESS_KEY_ID: 'KEY' });
239
+ const opts = (mockExeca.mock.calls[0] as unknown as unknown[])[2] as { env: Record<string, string> };
240
+ expect(opts.env['AWS_ACCESS_KEY_ID']).toBe('KEY');
139
241
  });
140
242
  });
141
243
 
@@ -161,87 +263,3 @@ describe('localHead', () => {
161
263
  expect(await localHead(DIR)).toBe('abc123');
162
264
  });
163
265
  });
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
+ }
@@ -0,0 +1,37 @@
1
+ import { defaultS3Remote } from './repo';
2
+
3
+ export const REMOTE_TYPES = ['s3', 'github'] as const;
4
+
5
+ export type RemoteType = (typeof REMOTE_TYPES)[number];
6
+
7
+ export interface RemoteOptions {
8
+ remote?: RemoteType;
9
+ repoUrl?: string;
10
+ }
11
+
12
+ /**
13
+ * Validates --remote and --repo-url option values.
14
+ * Pass `requireRemote: true` when the flag is mandatory (e.g. create).
15
+ */
16
+ export function validateRemoteOptions(opts: RemoteOptions, requireRemote = false): void {
17
+ if (!opts.remote) {
18
+ if (requireRemote) {
19
+ throw new Error(`--remote is required. Use --remote=s3 (shared storage) or --remote=github (your GitHub repo).`);
20
+ }
21
+ return;
22
+ }
23
+ if (!(REMOTE_TYPES as readonly string[]).includes(opts.remote)) {
24
+ throw new Error(`Unknown remote "${opts.remote}". Use ${REMOTE_TYPES.join(' or ')}.`);
25
+ }
26
+ if (opts.remote === 'github' && !opts.repoUrl) {
27
+ throw new Error('--repo-url is required when using --remote=github.');
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Resolves the git remote URL from --remote / --repo-url options.
33
+ * Call after validateRemoteOptions.
34
+ */
35
+ export function resolveRemoteUrl(remote: RemoteType, appName: string, repoUrl?: string): string {
36
+ return remote === 's3' ? defaultS3Remote(appName) : (repoUrl as string);
37
+ }
@@ -4,6 +4,18 @@ import { execa } from 'execa';
4
4
  import { BUCKET_WRITE_NAME } from '../../constants';
5
5
  import { mfAppName } from '../app-name';
6
6
 
7
+ type GitEnv = Record<string, string | undefined>;
8
+
9
+ function execGit(args: string[], dir: string, env?: GitEnv) {
10
+ return execa('git', args, { cwd: dir, env });
11
+ }
12
+
13
+ async function execGitLines(args: string[], dir: string, env?: GitEnv): Promise<string[]> {
14
+ return execGit(args, dir, env)
15
+ .then(r => r.stdout.split('\n').filter(Boolean))
16
+ .catch(() => []);
17
+ }
18
+
7
19
  export class GitConflictError extends Error {
8
20
  constructor(message: string) {
9
21
  super(message);
@@ -11,7 +23,14 @@ export class GitConflictError extends Error {
11
23
  }
12
24
  }
13
25
 
26
+ export interface ConflictInfo {
27
+ localCommits: string[];
28
+ remoteCommits: string[];
29
+ conflictingFiles: string[];
30
+ }
31
+
14
32
  const REQUIRED_GITIGNORE_ENTRIES = ['node_modules/', '.zth/', 'build/'];
33
+ const LOCAL_HOOKS_DIR = '.zth/hooks';
15
34
 
16
35
  export function defaultS3Remote(app: string): string {
17
36
  return `s3://${BUCKET_WRITE_NAME}/${mfAppName(app)}/git`;
@@ -19,26 +38,40 @@ export function defaultS3Remote(app: string): string {
19
38
 
20
39
  export async function initRepo(dir: string, remote: string): Promise<void> {
21
40
  await ensureGitignore(dir);
22
- await execa('git', ['init', '-b', 'master'], { cwd: dir });
23
- await execa('git', ['add', '.'], { cwd: dir });
24
- await execa('git', ['commit', '-m', 'Initial commit'], { cwd: dir });
25
- await execa('git', ['remote', 'add', 'origin', remote], { cwd: dir });
41
+ await execGit(['init', '-b', 'master'], dir);
42
+ await overrideGlobalHooks(dir);
43
+ await execGit(['add', '.'], dir);
44
+ await execGit(['commit', '-m', 'Initial commit'], dir);
45
+ await execGit(['remote', 'add', 'origin', remote], dir);
26
46
  }
27
47
 
28
48
  export async function getRemoteUrl(dir: string): Promise<string | undefined> {
29
- return execa('git', ['remote', 'get-url', 'origin'], { cwd: dir })
49
+ return execGit(['remote', 'get-url', 'origin'], dir)
30
50
  .then(r => r.stdout.trim() || undefined)
31
51
  .catch(() => undefined);
32
52
  }
33
53
 
34
- export async function cloneRepo(remote: string, dir: string): Promise<void> {
35
- await execa('git', ['clone', remote, dir]);
54
+ /**
55
+ * Throws if the working tree has uncommitted changes. Call only on a known git
56
+ * repo (e.g. guarded by a resolved remote) — a failing `git status` propagates
57
+ * rather than being silently treated as "clean".
58
+ */
59
+ export async function guardCleanTree(dir: string): Promise<void> {
60
+ const { stdout } = await execGit(['status', '--porcelain'], dir);
61
+ if (stdout.trim()) {
62
+ throw new Error('You have uncommitted changes — commit your changes before deploying.');
63
+ }
64
+ }
65
+
66
+ export async function cloneRepo(remote: string, dir: string, env: Record<string, string> = {}): Promise<void> {
67
+ await execa('git', ['clone', remote, dir], { env: { ...process.env, ...env } });
68
+ await overrideGlobalHooks(dir);
36
69
  }
37
70
 
38
71
  export async function safeBackupIfDirty(dir: string): Promise<void> {
39
72
  let status: string;
40
73
  try {
41
- ({ stdout: status } = await execa('git', ['status', '--porcelain'], { cwd: dir }));
74
+ ({ stdout: status } = await execGit(['status', '--porcelain'], dir));
42
75
  } catch {
43
76
  return;
44
77
  }
@@ -51,19 +84,55 @@ export async function safeBackupIfDirty(dir: string): Promise<void> {
51
84
  await fs.copy(dir, backupDir, { overwrite: false });
52
85
  }
53
86
 
54
- export async function pullAndResolve(dir: string): Promise<void> {
87
+ export async function pullAndResolve(dir: string, env?: Record<string, string>): Promise<void> {
88
+ const gitEnv = env ? { ...process.env, ...env } : undefined;
55
89
  try {
56
- await execa('git', ['pull', '--ff-only'], { cwd: dir });
57
- } catch {
58
- // TODO(Phase 7): safeBackupIfDirty(dir) → resolveDivergence(dir) instead of throwing
59
- throw new GitConflictError(
60
- 'This app changed while you were working — please re-open it to get the latest version.'
61
- );
90
+ await execGit(['pull', '--ff-only'], dir, gitEnv);
91
+ } catch (err) {
92
+ // First deploy: remote exists but has no commits yet — not a conflict.
93
+ const e = err as Error & { stderr?: string };
94
+ const msg = `${e.message ?? ''} ${e.stderr ?? ''}`;
95
+ if (/couldn't find remote ref|no tracking information|does not appear to be a git repository/i.test(msg)) {
96
+ return;
97
+ }
98
+ await safeBackupIfDirty(dir);
99
+ const info = await gatherConflictInfo(dir, gitEnv);
100
+ throw new GitConflictError(JSON.stringify(info));
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Pushes the local master branch to origin. On a rejected (non-fast-forward) push —
106
+ * i.e. a deploy landed while we were building — gathers structured conflict info and
107
+ * throws GitConflictError so the deploy stops before anything goes live (spec: push
108
+ * must stop the deploy before the bundle upload). The skill re-resolves and re-runs,
109
+ * which rebuilds against the latest. Non-conflict push errors re-throw as-is.
110
+ */
111
+ export async function pushToRemote(dir: string, env?: Record<string, string>): Promise<void> {
112
+ const gitEnv = env ? { ...process.env, ...env } : undefined;
113
+ try {
114
+ await execGit(['push', '-u', 'origin', 'master'], dir, gitEnv);
115
+ } catch (err) {
116
+ // execa puts the command in message but stderr in a separate field.
117
+ const e = err as Error & { stderr?: string };
118
+ const msg = `${e.message ?? ''} ${e.stderr ?? ''}`;
119
+ const isRejected = /\[rejected\]|non-fast-forward/.test(msg);
120
+ // git-remote-s3 emits EPIPE when git closes its stdin after a completed
121
+ // push (new commits or already-up-to-date). Treat EPIPE without a
122
+ // rejection marker as success — the push went through.
123
+ if (/EPIPE/.test(msg) && !isRejected) {
124
+ return;
125
+ }
126
+ if (isRejected) {
127
+ const info = await gatherConflictInfo(dir, gitEnv);
128
+ throw new GitConflictError(JSON.stringify(info));
129
+ }
130
+ throw err;
62
131
  }
63
132
  }
64
133
 
65
134
  export async function remoteHead(dir: string): Promise<string> {
66
- const { stdout } = await execa('git', ['ls-remote', 'origin', 'refs/heads/master'], { cwd: dir });
135
+ const { stdout } = await execGit(['ls-remote', 'origin', 'refs/heads/master'], dir);
67
136
  const sha = stdout.split('\t')[0]?.trim();
68
137
  if (!sha) {
69
138
  throw new Error('could not read remote head — is the remote reachable?');
@@ -72,43 +141,48 @@ export async function remoteHead(dir: string): Promise<string> {
72
141
  }
73
142
 
74
143
  export async function localHead(dir: string): Promise<string> {
75
- const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { cwd: dir });
144
+ const { stdout } = await execGit(['rev-parse', 'HEAD'], dir);
76
145
  return stdout.trim();
77
146
  }
78
147
 
79
- export async function commitDetailedAndPush(dir: string, version: number): Promise<void> {
80
- const { stdout: branch } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
81
- if (branch.trim() !== 'master') {
82
- throw new Error(`expected branch "master" but you are on "${branch.trim()}" — switch back before deploying.`);
83
- }
84
- await execa('git', ['add', '.'], { cwd: dir });
85
- const { stdout: staged } = await execa('git', ['diff', '--cached', '--name-only'], { cwd: dir });
86
- if (!staged.trim()) {
87
- throw new Error('Nothing new to deploy — no files have changed since the last deploy.');
148
+ async function gatherConflictInfo(dir: string, gitEnv?: GitEnv): Promise<ConflictInfo> {
149
+ await execGit(['fetch', 'origin'], dir, gitEnv).catch(() => {});
150
+
151
+ const [localCommits, remoteCommits, conflictingFiles] = await Promise.all([
152
+ execGitLines(['log', 'origin/master..HEAD', '--oneline'], dir, gitEnv),
153
+ execGitLines(['log', 'HEAD..origin/master', '--oneline'], dir, gitEnv),
154
+ execGitLines(['diff', '--name-only', 'HEAD', 'origin/master'], dir, gitEnv),
155
+ ]);
156
+
157
+ return { localCommits, remoteCommits, conflictingFiles };
158
+ }
159
+
160
+ /**
161
+ * If the user has a global core.hooksPath (e.g. ~/.global-git-hooks with a
162
+ * prevent-push-to-master script), override it locally so Z2H's non-interactive
163
+ * pushes aren't blocked. Other repos on the machine stay protected.
164
+ */
165
+ async function overrideGlobalHooks(dir: string): Promise<void> {
166
+ if (await fs.pathExists(path.join(dir, LOCAL_HOOKS_DIR))) {
167
+ return;
88
168
  }
89
- const message = await buildCommitMessage(dir, version);
90
- await execa('git', ['commit', '-m', message], { cwd: dir });
91
- try {
92
- await execa('git', ['push', '-u', 'origin', 'master'], { cwd: dir });
93
- } catch (err) {
94
- if (/\[rejected\]|non-fast-forward/.test((err as Error).message)) {
95
- throw new GitConflictError('push rejected — remote has new commits');
96
- }
97
- throw err;
169
+
170
+ const globalHooksPath = await execGit(['config', '--global', 'core.hooksPath'], dir)
171
+ .then(r => r.stdout.trim())
172
+ .catch(() => '');
173
+
174
+ if (!globalHooksPath) {
175
+ return;
98
176
  }
99
- }
100
177
 
101
- async function buildCommitMessage(dir: string, version: number): Promise<string> {
102
- // TODO(Phase 7): replace with LLM-generated summary using git diff + commit messages
103
- const { stdout } = await execa('git', ['status', '--short'], { cwd: dir });
104
- const files = stdout
105
- .split('\n')
106
- .map(line => line.slice(3).trim())
107
- .filter(Boolean);
108
- const fileList = files.length
109
- ? files.slice(0, 5).join(', ') + (files.length > 5 ? ` (+${files.length - 5} more)` : '')
110
- : 'no changes';
111
- return `v${version}: deploy — ${fileList}`;
178
+ await fs.ensureDir(path.join(dir, LOCAL_HOOKS_DIR));
179
+ await execGit(['config', '--local', 'core.hooksPath', LOCAL_HOOKS_DIR], dir);
180
+
181
+ // eslint-disable-next-line no-console
182
+ console.warn(
183
+ `⚠️ Detected global git hooks at ${globalHooksPath}\n` +
184
+ ` Overriding locally for this Z2H project (your other repos are unaffected).`
185
+ );
112
186
  }
113
187
 
114
188
  async function ensureGitignore(dir: string): Promise<void> {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Returns the machine app name when running as a service (e.g. agent-runner).
3
+ * Available when the process is a monday.com microservice with `APP_NAME` set
4
+ * by the platform runtime.
5
+ *
6
+ * Returns `undefined` for interactive human users (fallback to os.userInfo).
7
+ */
8
+ export function getMachineAppName(): string | undefined {
9
+ return process.env.APP_NAME || undefined;
10
+ }
@@ -1,23 +1,16 @@
1
1
  import { S3Client } from '@aws-sdk/client-s3';
2
- import { brokerCredentialProvider } from '../auth/broker-credential-provider';
3
2
  import { REGION } from '../../constants';
3
+ import type { AwsCredentialIdentity } from '@aws-sdk/types';
4
4
 
5
5
  let client: S3Client | null = null;
6
6
 
7
- /**
8
- * Initialize the per-process S3 client with broker-issued credentials scoped
9
- * to the given app's S3 prefix. Must be called once before any `getS3Client`.
10
- */
11
- export function setupS3Client({ appName }: { appName: string }): void {
12
- client = new S3Client({
13
- region: REGION,
14
- credentials: brokerCredentialProvider({ appName }),
15
- });
7
+ export function setupS3Client(credentials: AwsCredentialIdentity): void {
8
+ client = new S3Client({ region: REGION, credentials });
16
9
  }
17
10
 
18
11
  export function getS3Client(): S3Client {
19
12
  if (!client) {
20
- throw new Error('S3 client not initialized — call setupS3Client({ appName }) first.');
13
+ throw new Error('S3 client not initialized — call setupS3Client(credentials) first.');
21
14
  }
22
15
  return client;
23
16
  }