@mondaydotcomorg/z2h-cli 0.10.1 → 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 (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 +103 -47
  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 +104 -47
  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 +108 -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 +121 -47
  104. package/src/util/s3/client.ts +4 -11
@@ -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> {
@@ -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
  }