@mondaydotcomorg/z2h-cli 0.7.2 → 0.9.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 (90) hide show
  1. package/dist/commands/build.d.ts +1 -1
  2. package/dist/commands/build.d.ts.map +1 -1
  3. package/dist/commands/build.js +4 -3
  4. package/dist/commands/clean.d.ts +1 -1
  5. package/dist/commands/clean.d.ts.map +1 -1
  6. package/dist/commands/clean.js +5 -4
  7. package/dist/commands/create-workspace.d.ts +1 -1
  8. package/dist/commands/create-workspace.d.ts.map +1 -1
  9. package/dist/commands/create-workspace.js +14 -14
  10. package/dist/commands/create.d.ts +1 -1
  11. package/dist/commands/create.d.ts.map +1 -1
  12. package/dist/commands/create.js +27 -13
  13. package/dist/commands/deploy.d.ts +1 -1
  14. package/dist/commands/deploy.d.ts.map +1 -1
  15. package/dist/commands/deploy.js +45 -11
  16. package/dist/commands/dev.d.ts +1 -1
  17. package/dist/commands/dev.d.ts.map +1 -1
  18. package/dist/commands/dev.js +10 -6
  19. package/dist/commands/migrate.d.ts +1 -1
  20. package/dist/commands/migrate.d.ts.map +1 -1
  21. package/dist/commands/migrate.js +1 -1
  22. package/dist/esm/commands/build.d.ts +1 -1
  23. package/dist/esm/commands/build.d.ts.map +1 -1
  24. package/dist/esm/commands/build.mjs +2 -1
  25. package/dist/esm/commands/clean.d.ts +1 -1
  26. package/dist/esm/commands/clean.d.ts.map +1 -1
  27. package/dist/esm/commands/clean.mjs +2 -1
  28. package/dist/esm/commands/create-workspace.d.ts +1 -1
  29. package/dist/esm/commands/create-workspace.d.ts.map +1 -1
  30. package/dist/esm/commands/create-workspace.mjs +1 -1
  31. package/dist/esm/commands/create.d.ts +1 -1
  32. package/dist/esm/commands/create.d.ts.map +1 -1
  33. package/dist/esm/commands/create.mjs +16 -2
  34. package/dist/esm/commands/deploy.d.ts +1 -1
  35. package/dist/esm/commands/deploy.d.ts.map +1 -1
  36. package/dist/esm/commands/deploy.mjs +42 -8
  37. package/dist/esm/commands/dev.d.ts +1 -1
  38. package/dist/esm/commands/dev.d.ts.map +1 -1
  39. package/dist/esm/commands/dev.mjs +5 -1
  40. package/dist/esm/commands/migrate.d.ts +1 -1
  41. package/dist/esm/commands/migrate.d.ts.map +1 -1
  42. package/dist/esm/commands/migrate.mjs +1 -1
  43. package/dist/esm/index.mjs +4 -1
  44. package/dist/esm/migrations/auto-migrate.mjs +1 -1
  45. package/dist/esm/templates/app/claude-settings.mjs +1 -1
  46. package/dist/esm/util/git/__tests__/repo.test.d.ts +2 -0
  47. package/dist/esm/util/git/__tests__/repo.test.d.ts.map +1 -0
  48. package/dist/esm/util/git/repo.d.ts +12 -0
  49. package/dist/esm/util/git/repo.d.ts.map +1 -0
  50. package/dist/esm/util/git/repo.mjs +105 -0
  51. package/dist/esm/util/manifest.d.ts +1 -0
  52. package/dist/esm/util/manifest.d.ts.map +1 -1
  53. package/dist/index.js +4 -1
  54. package/dist/migrations/auto-migrate.js +11 -11
  55. package/dist/shadow/consumer-pkg.js +2 -2
  56. package/dist/shadow/gitignore.js +4 -4
  57. package/dist/shadow/merge-package-json.js +2 -2
  58. package/dist/shadow/metadata.js +4 -4
  59. package/dist/shadow/prepare.js +5 -5
  60. package/dist/shadow/scaffold.js +8 -8
  61. package/dist/shadow/sync.js +3 -3
  62. package/dist/templates/app/claude-settings.js +1 -1
  63. package/dist/util/auth/auth-store.js +4 -4
  64. package/dist/util/auth/write-auth.js +3 -3
  65. package/dist/util/git/__tests__/repo.test.d.ts +2 -0
  66. package/dist/util/git/__tests__/repo.test.d.ts.map +1 -0
  67. package/dist/util/git/repo.d.ts +12 -0
  68. package/dist/util/git/repo.d.ts.map +1 -0
  69. package/dist/util/git/repo.js +120 -0
  70. package/dist/util/manifest.d.ts +1 -0
  71. package/dist/util/manifest.d.ts.map +1 -1
  72. package/dist/util/s3/upload.js +2 -2
  73. package/dist/util/workspace.js +3 -3
  74. package/package.json +1 -1
  75. package/src/commands/__tests__/create-workspace.test.ts +4 -4
  76. package/src/commands/__tests__/create.test.ts +12 -5
  77. package/src/commands/build.ts +2 -1
  78. package/src/commands/clean.ts +2 -1
  79. package/src/commands/create-workspace.ts +1 -1
  80. package/src/commands/create.ts +15 -2
  81. package/src/commands/deploy.ts +43 -8
  82. package/src/commands/dev.ts +5 -1
  83. package/src/commands/migrate.ts +1 -1
  84. package/src/index.ts +4 -1
  85. package/src/migrations/auto-migrate.ts +3 -3
  86. package/src/templates/app/CLAUDE.md +3 -1
  87. package/src/templates/app/claude-settings.json +1 -1
  88. package/src/util/git/__tests__/repo.test.ts +208 -0
  89. package/src/util/git/repo.ts +112 -0
  90. package/src/util/manifest.ts +1 -0
@@ -0,0 +1,208 @@
1
+ import path from 'node:path';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { execa } from 'execa';
5
+ import fs from 'fs-extra';
6
+ import {
7
+ GitConflictError,
8
+ defaultS3Remote,
9
+ cloneRepo,
10
+ safeBackupIfDirty,
11
+ pullAndResolve,
12
+ remoteHead,
13
+ localHead,
14
+ commitDetailedAndPush,
15
+ initAndPush,
16
+ } from '../repo';
17
+
18
+ vi.mock('execa', () => ({ execa: vi.fn() }));
19
+ vi.mock('fs-extra', () => ({
20
+ default: {
21
+ copy: vi.fn(),
22
+ readFile: vi.fn(),
23
+ outputFile: vi.fn(),
24
+ pathExists: vi.fn(),
25
+ },
26
+ }));
27
+
28
+ const mockExeca = vi.mocked(execa);
29
+ const mockFs = vi.mocked(fs, true);
30
+
31
+ const DIR = '/home/user/zth-projects/my-app';
32
+ const execaOk = (stdout = '') => ({ stdout } as never);
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ mockFs.readFile.mockRejectedValue(new Error('ENOENT'));
37
+ mockFs.outputFile.mockResolvedValue(undefined);
38
+ mockFs.copy.mockResolvedValue(undefined);
39
+ });
40
+
41
+ // ── defaultS3Remote ──────────────────────────────────────────────────────────
42
+
43
+ describe('defaultS3Remote', () => {
44
+ it('returns the expected S3 URL', () => {
45
+ expect(defaultS3Remote('my-app')).toBe('s3://prod-use1-bigbrain-zth-mf-assets/my-app/git');
46
+ });
47
+ });
48
+
49
+ // ── cloneRepo ────────────────────────────────────────────────────────────────
50
+
51
+ describe('cloneRepo', () => {
52
+ it('calls git clone with correct args', async () => {
53
+ mockExeca.mockResolvedValueOnce(execaOk());
54
+ await cloneRepo('s3://bucket/my-app/git', DIR);
55
+ expect(mockExeca).toHaveBeenCalledWith('git', ['clone', 's3://bucket/my-app/git', DIR]);
56
+ });
57
+ });
58
+
59
+ // ── initAndPush ──────────────────────────────────────────────────────────────
60
+
61
+ describe('initAndPush', () => {
62
+ it('runs the correct git command sequence', async () => {
63
+ mockExeca.mockResolvedValue(execaOk());
64
+
65
+ await initAndPush(DIR, 's3://bucket/my-app/git');
66
+
67
+ const calls = mockExeca.mock.calls.map(c => c.slice(0, 2));
68
+ expect(calls).toContainEqual(['git', ['init', '-b', 'master']]);
69
+ expect(calls).toContainEqual(['git', ['add', '.']]);
70
+ expect(calls).toContainEqual(['git', ['commit', '-m', 'Initial commit']]);
71
+ expect(calls).toContainEqual(['git', ['remote', 'add', 'origin', 's3://bucket/my-app/git']]);
72
+ expect(calls).toContainEqual(['git', ['push', '-u', 'origin', 'master']]);
73
+ });
74
+
75
+ it('writes missing gitignore entries', async () => {
76
+ mockFs.readFile.mockResolvedValueOnce('' as never);
77
+ mockExeca.mockResolvedValue(execaOk());
78
+
79
+ await initAndPush(DIR, 's3://bucket/my-app/git');
80
+
81
+ expect(mockFs.outputFile).toHaveBeenCalledWith(
82
+ path.join(DIR, '.gitignore'),
83
+ expect.stringContaining('node_modules/')
84
+ );
85
+ });
86
+
87
+ it('skips gitignore write when all entries already present', async () => {
88
+ mockFs.readFile.mockResolvedValueOnce('node_modules/\n.zth/\nbuild/\n' as never);
89
+ mockExeca.mockResolvedValue(execaOk());
90
+
91
+ await initAndPush(DIR, 's3://bucket/my-app/git');
92
+
93
+ expect(mockFs.outputFile).not.toHaveBeenCalled();
94
+ });
95
+ });
96
+
97
+ // ── safeBackupIfDirty ────────────────────────────────────────────────────────
98
+
99
+ describe('safeBackupIfDirty', () => {
100
+ it('copies dir to backup when working tree is dirty', async () => {
101
+ mockExeca
102
+ .mockResolvedValueOnce(execaOk('M src/index.ts')) // git status --porcelain
103
+ .mockResolvedValueOnce(execaOk('abc123')); // git rev-parse HEAD
104
+
105
+ await safeBackupIfDirty(DIR);
106
+
107
+ expect(mockFs.copy).toHaveBeenCalledWith(DIR, '/home/user/zth-projects/my-app.backup-abc123', { overwrite: false });
108
+ });
109
+
110
+ it('skips backup when working tree is clean', async () => {
111
+ mockExeca.mockResolvedValueOnce(execaOk('')); // git status --porcelain — empty
112
+
113
+ await safeBackupIfDirty(DIR);
114
+
115
+ expect(mockFs.copy).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('skips backup silently when not a git repo', async () => {
119
+ mockExeca.mockRejectedValueOnce(new Error('not a git repository'));
120
+
121
+ await expect(safeBackupIfDirty(DIR)).resolves.toBeUndefined();
122
+ expect(mockFs.copy).not.toHaveBeenCalled();
123
+ });
124
+ });
125
+
126
+ // ── pullAndResolve ───────────────────────────────────────────────────────────
127
+
128
+ describe('pullAndResolve', () => {
129
+ it('resolves when pull fast-forwards cleanly', async () => {
130
+ mockExeca.mockResolvedValueOnce(execaOk()); // git pull --ff-only
131
+
132
+ await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
133
+ });
134
+
135
+ it('throws GitConflictError on non-fast-forward', async () => {
136
+ mockExeca.mockRejectedValueOnce(new Error('fatal: Not possible to fast-forward')); // git pull
137
+
138
+ await expect(pullAndResolve(DIR)).rejects.toThrow(GitConflictError);
139
+ });
140
+ });
141
+
142
+ // ── remoteHead ───────────────────────────────────────────────────────────────
143
+
144
+ describe('remoteHead', () => {
145
+ it('parses the SHA from ls-remote output', async () => {
146
+ mockExeca.mockResolvedValueOnce(execaOk('deadbeef\trefs/heads/master'));
147
+ expect(await remoteHead(DIR)).toBe('deadbeef');
148
+ });
149
+
150
+ it('throws when ls-remote returns empty', async () => {
151
+ mockExeca.mockResolvedValueOnce(execaOk(''));
152
+ await expect(remoteHead(DIR)).rejects.toThrow('could not read remote head');
153
+ });
154
+ });
155
+
156
+ // ── localHead ────────────────────────────────────────────────────────────────
157
+
158
+ describe('localHead', () => {
159
+ it('returns trimmed SHA', async () => {
160
+ mockExeca.mockResolvedValueOnce(execaOk('abc123\n'));
161
+ expect(await localHead(DIR)).toBe('abc123');
162
+ });
163
+ });
164
+
165
+ // ── commitDetailedAndPush ────────────────────────────────────────────────────
166
+
167
+ describe('commitDetailedAndPush', () => {
168
+ it('commits with a version-prefixed message and pushes', async () => {
169
+ mockExeca
170
+ .mockResolvedValueOnce(execaOk(' M src/index.ts\n?? new-file.ts')) // git status --short (XY + space prefix)
171
+ .mockResolvedValueOnce(execaOk()) // git add .
172
+ .mockResolvedValueOnce(execaOk()) // git commit
173
+ .mockResolvedValueOnce(execaOk()); // git push
174
+
175
+ await commitDetailedAndPush(DIR, 3);
176
+
177
+ const commitCall = mockExeca.mock.calls.find(c => c[0] === 'git' && (c[1] as string[])[0] === 'commit');
178
+ expect(commitCall).toBeDefined();
179
+ const msg = (commitCall![1] as string[])[2];
180
+ expect(msg).toMatch(/^v3:/);
181
+ expect(msg).toContain('src/index.ts');
182
+ });
183
+
184
+ it('throws GitConflictError when push is rejected', async () => {
185
+ mockExeca
186
+ .mockResolvedValueOnce(execaOk('')) // git status --short
187
+ .mockResolvedValueOnce(execaOk()) // git add .
188
+ .mockResolvedValueOnce(execaOk()) // git commit
189
+ .mockRejectedValueOnce(new Error('[rejected]')); // git push
190
+
191
+ await expect(commitDetailedAndPush(DIR, 4)).rejects.toThrow(GitConflictError);
192
+ });
193
+
194
+ it('truncates long file lists to 5 entries', async () => {
195
+ const manyFiles = Array.from({ length: 8 }, (_, i) => ` M file${i}.ts`).join('\n');
196
+ mockExeca
197
+ .mockResolvedValueOnce(execaOk(manyFiles))
198
+ .mockResolvedValueOnce(execaOk())
199
+ .mockResolvedValueOnce(execaOk())
200
+ .mockResolvedValueOnce(execaOk());
201
+
202
+ await commitDetailedAndPush(DIR, 1);
203
+
204
+ const commitCall = mockExeca.mock.calls.find(c => c[0] === 'git' && (c[1] as string[])[0] === 'commit');
205
+ const msg = (commitCall![1] as string[])[2];
206
+ expect(msg).toContain('+3 more');
207
+ });
208
+ });
@@ -0,0 +1,112 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { execa } from 'execa';
4
+ import { BUCKET_WRITE_NAME } from '../../constants';
5
+
6
+ export class GitConflictError extends Error {
7
+ constructor(message: string) {
8
+ super(message);
9
+ this.name = 'GitConflictError';
10
+ }
11
+ }
12
+
13
+ const REQUIRED_GITIGNORE_ENTRIES = ['node_modules/', '.zth/', 'build/'];
14
+
15
+ export function defaultS3Remote(app: string): string {
16
+ return `s3://${BUCKET_WRITE_NAME}/${app}/git`;
17
+ }
18
+
19
+ export async function initAndPush(dir: string, remote: string): Promise<void> {
20
+ await ensureGitignore(dir);
21
+ await execa('git', ['init', '-b', 'master'], { cwd: dir });
22
+ await execa('git', ['add', '.'], { cwd: dir });
23
+ await execa('git', ['commit', '-m', 'Initial commit'], { cwd: dir });
24
+ await execa('git', ['remote', 'add', 'origin', remote], { cwd: dir });
25
+ await execa('git', ['push', '-u', 'origin', 'master'], { cwd: dir });
26
+ }
27
+
28
+ export async function cloneRepo(remote: string, dir: string): Promise<void> {
29
+ await execa('git', ['clone', remote, dir]);
30
+ }
31
+
32
+ export async function safeBackupIfDirty(dir: string): Promise<void> {
33
+ let status: string;
34
+ try {
35
+ ({ stdout: status } = await execa('git', ['status', '--porcelain'], { cwd: dir }));
36
+ } catch {
37
+ return;
38
+ }
39
+ if (!status.trim()) {
40
+ return;
41
+ }
42
+ const sha = await localHead(dir).catch(() => 'unknown');
43
+ const appName = path.basename(dir);
44
+ const backupDir = path.join(path.dirname(dir), `${appName}.backup-${sha}`);
45
+ await fs.copy(dir, backupDir, { overwrite: false });
46
+ }
47
+
48
+ export async function pullAndResolve(dir: string): Promise<void> {
49
+ try {
50
+ await execa('git', ['pull', '--ff-only'], { cwd: dir });
51
+ } catch {
52
+ // TODO(Phase 7): safeBackupIfDirty(dir) → resolveDivergence(dir) instead of throwing
53
+ throw new GitConflictError(
54
+ 'This app changed while you were working — please re-open it to get the latest version.'
55
+ );
56
+ }
57
+ }
58
+
59
+ export async function remoteHead(dir: string): Promise<string> {
60
+ const { stdout } = await execa('git', ['ls-remote', 'origin', 'refs/heads/master'], { cwd: dir });
61
+ const sha = stdout.split('\t')[0]?.trim();
62
+ if (!sha) {
63
+ throw new Error('could not read remote head — is the remote reachable?');
64
+ }
65
+ return sha;
66
+ }
67
+
68
+ export async function localHead(dir: string): Promise<string> {
69
+ const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { cwd: dir });
70
+ return stdout.trim();
71
+ }
72
+
73
+ export async function commitDetailedAndPush(dir: string, version: number): Promise<void> {
74
+ const message = await buildCommitMessage(dir, version);
75
+ await execa('git', ['add', '.'], { cwd: dir });
76
+ await execa('git', ['commit', '-m', message], { cwd: dir });
77
+ try {
78
+ await execa('git', ['push'], { cwd: dir });
79
+ } catch {
80
+ throw new GitConflictError('push rejected — remote has new commits');
81
+ }
82
+ }
83
+
84
+ async function buildCommitMessage(dir: string, version: number): Promise<string> {
85
+ // TODO(Phase 7): replace with LLM-generated summary using git diff + commit messages
86
+ const { stdout } = await execa('git', ['status', '--short'], { cwd: dir });
87
+ const files = stdout
88
+ .split('\n')
89
+ .map(line => line.slice(3).trim())
90
+ .filter(Boolean);
91
+ const fileList = files.length
92
+ ? files.slice(0, 5).join(', ') + (files.length > 5 ? ` (+${files.length - 5} more)` : '')
93
+ : 'no changes';
94
+ return `v${version}: deploy — ${fileList}`;
95
+ }
96
+
97
+ async function ensureGitignore(dir: string): Promise<void> {
98
+ const gitignorePath = path.join(dir, '.gitignore');
99
+ let content = '';
100
+ try {
101
+ content = await fs.readFile(gitignorePath, 'utf8');
102
+ } catch {
103
+ // file doesn't exist yet
104
+ }
105
+ const lines = content.split('\n').map(l => l.trim());
106
+ const missing = REQUIRED_GITIGNORE_ENTRIES.filter(entry => !lines.includes(entry));
107
+ if (missing.length === 0) {
108
+ return;
109
+ }
110
+ const separator = content && !content.endsWith('\n') ? '\n' : '';
111
+ await fs.outputFile(gitignorePath, content + separator + missing.join('\n') + '\n');
112
+ }
@@ -23,6 +23,7 @@ export interface AssetManifestFile {
23
23
  nonExternalsEntrypoints?: string[];
24
24
  scriptType?: 'module' | 'classic';
25
25
  mfs?: string[];
26
+ gitRemote: string;
26
27
  }
27
28
 
28
29
  export interface ManifestEntry extends AssetManifestFile {