@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.
- package/dist/commands/build.d.ts +1 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +4 -3
- package/dist/commands/clean.d.ts +1 -1
- package/dist/commands/clean.d.ts.map +1 -1
- package/dist/commands/clean.js +5 -4
- package/dist/commands/create-workspace.d.ts +1 -1
- package/dist/commands/create-workspace.d.ts.map +1 -1
- package/dist/commands/create-workspace.js +14 -14
- package/dist/commands/create.d.ts +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +27 -13
- package/dist/commands/deploy.d.ts +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +45 -11
- package/dist/commands/dev.d.ts +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +10 -6
- package/dist/commands/migrate.d.ts +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +1 -1
- package/dist/esm/commands/build.d.ts +1 -1
- package/dist/esm/commands/build.d.ts.map +1 -1
- package/dist/esm/commands/build.mjs +2 -1
- package/dist/esm/commands/clean.d.ts +1 -1
- package/dist/esm/commands/clean.d.ts.map +1 -1
- package/dist/esm/commands/clean.mjs +2 -1
- package/dist/esm/commands/create-workspace.d.ts +1 -1
- package/dist/esm/commands/create-workspace.d.ts.map +1 -1
- package/dist/esm/commands/create-workspace.mjs +1 -1
- package/dist/esm/commands/create.d.ts +1 -1
- package/dist/esm/commands/create.d.ts.map +1 -1
- package/dist/esm/commands/create.mjs +16 -2
- package/dist/esm/commands/deploy.d.ts +1 -1
- package/dist/esm/commands/deploy.d.ts.map +1 -1
- package/dist/esm/commands/deploy.mjs +42 -8
- package/dist/esm/commands/dev.d.ts +1 -1
- package/dist/esm/commands/dev.d.ts.map +1 -1
- package/dist/esm/commands/dev.mjs +5 -1
- package/dist/esm/commands/migrate.d.ts +1 -1
- package/dist/esm/commands/migrate.d.ts.map +1 -1
- package/dist/esm/commands/migrate.mjs +1 -1
- package/dist/esm/index.mjs +4 -1
- package/dist/esm/migrations/auto-migrate.mjs +1 -1
- package/dist/esm/templates/app/claude-settings.mjs +1 -1
- package/dist/esm/util/git/__tests__/repo.test.d.ts +2 -0
- package/dist/esm/util/git/__tests__/repo.test.d.ts.map +1 -0
- package/dist/esm/util/git/repo.d.ts +12 -0
- package/dist/esm/util/git/repo.d.ts.map +1 -0
- package/dist/esm/util/git/repo.mjs +105 -0
- package/dist/esm/util/manifest.d.ts +1 -0
- package/dist/esm/util/manifest.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/migrations/auto-migrate.js +11 -11
- package/dist/shadow/consumer-pkg.js +2 -2
- package/dist/shadow/gitignore.js +4 -4
- package/dist/shadow/merge-package-json.js +2 -2
- package/dist/shadow/metadata.js +4 -4
- package/dist/shadow/prepare.js +5 -5
- package/dist/shadow/scaffold.js +8 -8
- package/dist/shadow/sync.js +3 -3
- package/dist/templates/app/claude-settings.js +1 -1
- package/dist/util/auth/auth-store.js +4 -4
- package/dist/util/auth/write-auth.js +3 -3
- package/dist/util/git/__tests__/repo.test.d.ts +2 -0
- package/dist/util/git/__tests__/repo.test.d.ts.map +1 -0
- package/dist/util/git/repo.d.ts +12 -0
- package/dist/util/git/repo.d.ts.map +1 -0
- package/dist/util/git/repo.js +120 -0
- package/dist/util/manifest.d.ts +1 -0
- package/dist/util/manifest.d.ts.map +1 -1
- package/dist/util/s3/upload.js +2 -2
- package/dist/util/workspace.js +3 -3
- package/package.json +1 -1
- package/src/commands/__tests__/create-workspace.test.ts +4 -4
- package/src/commands/__tests__/create.test.ts +12 -5
- package/src/commands/build.ts +2 -1
- package/src/commands/clean.ts +2 -1
- package/src/commands/create-workspace.ts +1 -1
- package/src/commands/create.ts +15 -2
- package/src/commands/deploy.ts +43 -8
- package/src/commands/dev.ts +5 -1
- package/src/commands/migrate.ts +1 -1
- package/src/index.ts +4 -1
- package/src/migrations/auto-migrate.ts +3 -3
- package/src/templates/app/CLAUDE.md +3 -1
- package/src/templates/app/claude-settings.json +1 -1
- package/src/util/git/__tests__/repo.test.ts +208 -0
- package/src/util/git/repo.ts +112 -0
- 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
|
+
}
|