@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 @@
1
+ {"version":3,"file":"git-env.d.ts","sourceRoot":"","sources":["../../../src/util/git/git-env.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC,CAU7C"}
@@ -0,0 +1,24 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ const util_auth_brokerCredentialProvider = require('../auth/broker-credential-provider.js');
4
+
5
+ /**
6
+ * Builds the AWS_* environment that `git-remote-s3` needs to talk to an S3-backed
7
+ * git remote, by minting short-lived broker credentials scoped to the app.
8
+ *
9
+ * Returns `undefined` for non-S3 remotes (GitHub apps ride the user's own git
10
+ * credential helper) and when there is no remote at all.
11
+ */
12
+ async function resolveGitEnv(appName, gitRemote) {
13
+ if (!gitRemote?.startsWith('s3://')) {
14
+ return undefined;
15
+ }
16
+ const creds = await util_auth_brokerCredentialProvider.brokerCredentialProvider({ appName })();
17
+ return {
18
+ AWS_ACCESS_KEY_ID: creds.accessKeyId,
19
+ AWS_SECRET_ACCESS_KEY: creds.secretAccessKey,
20
+ AWS_SESSION_TOKEN: creds.sessionToken ?? '',
21
+ };
22
+ }
23
+
24
+ exports.resolveGitEnv = resolveGitEnv;
@@ -0,0 +1,17 @@
1
+ export declare const REMOTE_TYPES: readonly ["s3", "github"];
2
+ export type RemoteType = (typeof REMOTE_TYPES)[number];
3
+ export interface RemoteOptions {
4
+ remote?: RemoteType;
5
+ repoUrl?: string;
6
+ }
7
+ /**
8
+ * Validates --remote and --repo-url option values.
9
+ * Pass `requireRemote: true` when the flag is mandatory (e.g. create).
10
+ */
11
+ export declare function validateRemoteOptions(opts: RemoteOptions, requireRemote?: boolean): void;
12
+ /**
13
+ * Resolves the git remote URL from --remote / --repo-url options.
14
+ * Call after validateRemoteOptions.
15
+ */
16
+ export declare function resolveRemoteUrl(remote: RemoteType, appName: string, repoUrl?: string): string;
17
+ //# sourceMappingURL=remote-options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-options.d.ts","sourceRoot":"","sources":["../../../src/util/git/remote-options.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,2BAA4B,CAAC;AAEtD,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;AAEvD,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,aAAa,EAAE,aAAa,UAAQ,GAAG,IAAI,CAatF;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAE9F"}
@@ -0,0 +1,34 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ const util_git_repo = require('./repo.js');
4
+
5
+ const REMOTE_TYPES = ['s3', 'github'];
6
+ /**
7
+ * Validates --remote and --repo-url option values.
8
+ * Pass `requireRemote: true` when the flag is mandatory (e.g. create).
9
+ */
10
+ function validateRemoteOptions(opts, requireRemote = false) {
11
+ if (!opts.remote) {
12
+ if (requireRemote) {
13
+ throw new Error(`--remote is required. Use --remote=s3 (shared storage) or --remote=github (your GitHub repo).`);
14
+ }
15
+ return;
16
+ }
17
+ if (!REMOTE_TYPES.includes(opts.remote)) {
18
+ throw new Error(`Unknown remote "${opts.remote}". Use ${REMOTE_TYPES.join(' or ')}.`);
19
+ }
20
+ if (opts.remote === 'github' && !opts.repoUrl) {
21
+ throw new Error('--repo-url is required when using --remote=github.');
22
+ }
23
+ }
24
+ /**
25
+ * Resolves the git remote URL from --remote / --repo-url options.
26
+ * Call after validateRemoteOptions.
27
+ */
28
+ function resolveRemoteUrl(remote, appName, repoUrl) {
29
+ return remote === 's3' ? util_git_repo.defaultS3Remote(appName) : repoUrl;
30
+ }
31
+
32
+ exports.REMOTE_TYPES = REMOTE_TYPES;
33
+ exports.resolveRemoteUrl = resolveRemoteUrl;
34
+ exports.validateRemoteOptions = validateRemoteOptions;
@@ -1,13 +1,31 @@
1
1
  export declare class GitConflictError extends Error {
2
2
  constructor(message: string);
3
3
  }
4
+ export interface ConflictInfo {
5
+ localCommits: string[];
6
+ remoteCommits: string[];
7
+ conflictingFiles: string[];
8
+ }
4
9
  export declare function defaultS3Remote(app: string): string;
5
10
  export declare function initRepo(dir: string, remote: string): Promise<void>;
6
11
  export declare function getRemoteUrl(dir: string): Promise<string | undefined>;
7
- export declare function cloneRepo(remote: string, dir: string): Promise<void>;
12
+ /**
13
+ * Throws if the working tree has uncommitted changes. Call only on a known git
14
+ * repo (e.g. guarded by a resolved remote) — a failing `git status` propagates
15
+ * rather than being silently treated as "clean".
16
+ */
17
+ export declare function guardCleanTree(dir: string): Promise<void>;
18
+ export declare function cloneRepo(remote: string, dir: string, env?: Record<string, string>): Promise<void>;
8
19
  export declare function safeBackupIfDirty(dir: string): Promise<void>;
9
- export declare function pullAndResolve(dir: string): Promise<void>;
20
+ export declare function pullAndResolve(dir: string, env?: Record<string, string>): Promise<void>;
21
+ /**
22
+ * Pushes the local master branch to origin. On a rejected (non-fast-forward) push —
23
+ * i.e. a deploy landed while we were building — gathers structured conflict info and
24
+ * throws GitConflictError so the deploy stops before anything goes live (spec: push
25
+ * must stop the deploy before the bundle upload). The skill re-resolves and re-runs,
26
+ * which rebuilds against the latest. Non-conflict push errors re-throw as-is.
27
+ */
28
+ export declare function pushToRemote(dir: string, env?: Record<string, string>): Promise<void>;
10
29
  export declare function remoteHead(dir: string): Promise<string>;
11
30
  export declare function localHead(dir: string): Promise<string>;
12
- export declare function commitDetailedAndPush(dir: string, version: number): Promise<void>;
13
31
  //# sourceMappingURL=repo.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"repo.d.ts","sourceRoot":"","sources":["../../../src/util/git/repo.ts"],"names":[],"mappings":"AAMA,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAID,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMzE;AAED,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI3E;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE1E;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAclE;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/D;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO7D;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5D;AAED,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoBvF"}
1
+ {"version":3,"file":"repo.d.ts","sourceRoot":"","sources":["../../../src/util/git/repo.ts"],"names":[],"mappings":"AAkBA,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAKD,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAOzE;AAED,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI3E;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAK/D;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG5G;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAclE;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAe7F;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB3F;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO7D;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5D"}
@@ -11,6 +11,14 @@ const _interopDefault = e => e && e.__esModule ? e : { default: e };
11
11
  const path__default = /*#__PURE__*/_interopDefault(path);
12
12
  const fs__default = /*#__PURE__*/_interopDefault(fs);
13
13
 
14
+ function execGit(args, dir, env) {
15
+ return execa.execa('git', args, { cwd: dir, env });
16
+ }
17
+ async function execGitLines(args, dir, env) {
18
+ return execGit(args, dir, env)
19
+ .then(r => r.stdout.split('\n').filter(Boolean))
20
+ .catch(() => []);
21
+ }
14
22
  class GitConflictError extends Error {
15
23
  constructor(message) {
16
24
  super(message);
@@ -18,28 +26,42 @@ class GitConflictError extends Error {
18
26
  }
19
27
  }
20
28
  const REQUIRED_GITIGNORE_ENTRIES = ['node_modules/', '.zth/', 'build/'];
29
+ const LOCAL_HOOKS_DIR = '.zth/hooks';
21
30
  function defaultS3Remote(app) {
22
31
  return `s3://${constants.BUCKET_WRITE_NAME}/${util_appName.mfAppName(app)}/git`;
23
32
  }
24
33
  async function initRepo(dir, remote) {
25
34
  await ensureGitignore(dir);
26
- await execa.execa('git', ['init', '-b', 'master'], { cwd: dir });
27
- await execa.execa('git', ['add', '.'], { cwd: dir });
28
- await execa.execa('git', ['commit', '-m', 'Initial commit'], { cwd: dir });
29
- await execa.execa('git', ['remote', 'add', 'origin', remote], { cwd: dir });
35
+ await execGit(['init', '-b', 'master'], dir);
36
+ await overrideGlobalHooks(dir);
37
+ await execGit(['add', '.'], dir);
38
+ await execGit(['commit', '-m', 'Initial commit'], dir);
39
+ await execGit(['remote', 'add', 'origin', remote], dir);
30
40
  }
31
41
  async function getRemoteUrl(dir) {
32
- return execa.execa('git', ['remote', 'get-url', 'origin'], { cwd: dir })
42
+ return execGit(['remote', 'get-url', 'origin'], dir)
33
43
  .then(r => r.stdout.trim() || undefined)
34
44
  .catch(() => undefined);
35
45
  }
36
- async function cloneRepo(remote, dir) {
37
- await execa.execa('git', ['clone', remote, dir]);
46
+ /**
47
+ * Throws if the working tree has uncommitted changes. Call only on a known git
48
+ * repo (e.g. guarded by a resolved remote) — a failing `git status` propagates
49
+ * rather than being silently treated as "clean".
50
+ */
51
+ async function guardCleanTree(dir) {
52
+ const { stdout } = await execGit(['status', '--porcelain'], dir);
53
+ if (stdout.trim()) {
54
+ throw new Error('You have uncommitted changes — commit your changes before deploying.');
55
+ }
56
+ }
57
+ async function cloneRepo(remote, dir, env = {}) {
58
+ await execa.execa('git', ['clone', remote, dir], { env: { ...process.env, ...env } });
59
+ await overrideGlobalHooks(dir);
38
60
  }
39
61
  async function safeBackupIfDirty(dir) {
40
62
  let status;
41
63
  try {
42
- ({ stdout: status } = await execa.execa('git', ['status', '--porcelain'], { cwd: dir }));
64
+ ({ stdout: status } = await execGit(['status', '--porcelain'], dir));
43
65
  }
44
66
  catch {
45
67
  return;
@@ -52,17 +74,55 @@ async function safeBackupIfDirty(dir) {
52
74
  const backupDir = path__default.default.join(path__default.default.dirname(dir), `${appName}.backup-${sha}`);
53
75
  await fs__default.default.copy(dir, backupDir, { overwrite: false });
54
76
  }
55
- async function pullAndResolve(dir) {
77
+ async function pullAndResolve(dir, env) {
78
+ const gitEnv = env ? { ...process.env, ...env } : undefined;
56
79
  try {
57
- await execa.execa('git', ['pull', '--ff-only'], { cwd: dir });
80
+ await execGit(['pull', '--ff-only'], dir, gitEnv);
58
81
  }
59
- catch {
60
- // TODO(Phase 7): safeBackupIfDirty(dir) → resolveDivergence(dir) instead of throwing
61
- throw new GitConflictError('This app changed while you were working — please re-open it to get the latest version.');
82
+ catch (err) {
83
+ // First deploy: remote exists but has no commits yet — not a conflict.
84
+ const e = err;
85
+ const msg = `${e.message ?? ''} ${e.stderr ?? ''}`;
86
+ if (/couldn't find remote ref|no tracking information|does not appear to be a git repository/i.test(msg)) {
87
+ return;
88
+ }
89
+ await safeBackupIfDirty(dir);
90
+ const info = await gatherConflictInfo(dir, gitEnv);
91
+ throw new GitConflictError(JSON.stringify(info));
92
+ }
93
+ }
94
+ /**
95
+ * Pushes the local master branch to origin. On a rejected (non-fast-forward) push —
96
+ * i.e. a deploy landed while we were building — gathers structured conflict info and
97
+ * throws GitConflictError so the deploy stops before anything goes live (spec: push
98
+ * must stop the deploy before the bundle upload). The skill re-resolves and re-runs,
99
+ * which rebuilds against the latest. Non-conflict push errors re-throw as-is.
100
+ */
101
+ async function pushToRemote(dir, env) {
102
+ const gitEnv = env ? { ...process.env, ...env } : undefined;
103
+ try {
104
+ await execGit(['push', '-u', 'origin', 'master'], dir, gitEnv);
105
+ }
106
+ catch (err) {
107
+ // execa puts the command in message but stderr in a separate field.
108
+ const e = err;
109
+ const msg = `${e.message ?? ''} ${e.stderr ?? ''}`;
110
+ const isRejected = /\[rejected\]|non-fast-forward/.test(msg);
111
+ // git-remote-s3 emits EPIPE when git closes its stdin after a completed
112
+ // push (new commits or already-up-to-date). Treat EPIPE without a
113
+ // rejection marker as success — the push went through.
114
+ if (/EPIPE/.test(msg) && !isRejected) {
115
+ return;
116
+ }
117
+ if (isRejected) {
118
+ const info = await gatherConflictInfo(dir, gitEnv);
119
+ throw new GitConflictError(JSON.stringify(info));
120
+ }
121
+ throw err;
62
122
  }
63
123
  }
64
124
  async function remoteHead(dir) {
65
- const { stdout } = await execa.execa('git', ['ls-remote', 'origin', 'refs/heads/master'], { cwd: dir });
125
+ const { stdout } = await execGit(['ls-remote', 'origin', 'refs/heads/master'], dir);
66
126
  const sha = stdout.split('\t')[0]?.trim();
67
127
  if (!sha) {
68
128
  throw new Error('could not read remote head — is the remote reachable?');
@@ -70,42 +130,38 @@ async function remoteHead(dir) {
70
130
  return sha;
71
131
  }
72
132
  async function localHead(dir) {
73
- const { stdout } = await execa.execa('git', ['rev-parse', 'HEAD'], { cwd: dir });
133
+ const { stdout } = await execGit(['rev-parse', 'HEAD'], dir);
74
134
  return stdout.trim();
75
135
  }
76
- async function commitDetailedAndPush(dir, version) {
77
- const { stdout: branch } = await execa.execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
78
- if (branch.trim() !== 'master') {
79
- throw new Error(`expected branch "master" but you are on "${branch.trim()}" — switch back before deploying.`);
80
- }
81
- await execa.execa('git', ['add', '.'], { cwd: dir });
82
- const { stdout: staged } = await execa.execa('git', ['diff', '--cached', '--name-only'], { cwd: dir });
83
- if (!staged.trim()) {
84
- throw new Error('Nothing new to deploy — no files have changed since the last deploy.');
85
- }
86
- const message = await buildCommitMessage(dir, version);
87
- await execa.execa('git', ['commit', '-m', message], { cwd: dir });
88
- try {
89
- await execa.execa('git', ['push', '-u', 'origin', 'master'], { cwd: dir });
136
+ async function gatherConflictInfo(dir, gitEnv) {
137
+ await execGit(['fetch', 'origin'], dir, gitEnv).catch(() => { });
138
+ const [localCommits, remoteCommits, conflictingFiles] = await Promise.all([
139
+ execGitLines(['log', 'origin/master..HEAD', '--oneline'], dir, gitEnv),
140
+ execGitLines(['log', 'HEAD..origin/master', '--oneline'], dir, gitEnv),
141
+ execGitLines(['diff', '--name-only', 'HEAD', 'origin/master'], dir, gitEnv),
142
+ ]);
143
+ return { localCommits, remoteCommits, conflictingFiles };
144
+ }
145
+ /**
146
+ * If the user has a global core.hooksPath (e.g. ~/.global-git-hooks with a
147
+ * prevent-push-to-master script), override it locally so Z2H's non-interactive
148
+ * pushes aren't blocked. Other repos on the machine stay protected.
149
+ */
150
+ async function overrideGlobalHooks(dir) {
151
+ if (await fs__default.default.pathExists(path__default.default.join(dir, LOCAL_HOOKS_DIR))) {
152
+ return;
90
153
  }
91
- catch (err) {
92
- if (/\[rejected\]|non-fast-forward/.test(err.message)) {
93
- throw new GitConflictError('push rejected — remote has new commits');
94
- }
95
- throw err;
154
+ const globalHooksPath = await execGit(['config', '--global', 'core.hooksPath'], dir)
155
+ .then(r => r.stdout.trim())
156
+ .catch(() => '');
157
+ if (!globalHooksPath) {
158
+ return;
96
159
  }
97
- }
98
- async function buildCommitMessage(dir, version) {
99
- // TODO(Phase 7): replace with LLM-generated summary using git diff + commit messages
100
- const { stdout } = await execa.execa('git', ['status', '--short'], { cwd: dir });
101
- const files = stdout
102
- .split('\n')
103
- .map(line => line.slice(3).trim())
104
- .filter(Boolean);
105
- const fileList = files.length
106
- ? files.slice(0, 5).join(', ') + (files.length > 5 ? ` (+${files.length - 5} more)` : '')
107
- : 'no changes';
108
- return `v${version}: deploy — ${fileList}`;
160
+ await fs__default.default.ensureDir(path__default.default.join(dir, LOCAL_HOOKS_DIR));
161
+ await execGit(['config', '--local', 'core.hooksPath', LOCAL_HOOKS_DIR], dir);
162
+ // eslint-disable-next-line no-console
163
+ console.warn(`⚠️ Detected global git hooks at ${globalHooksPath}\n` +
164
+ ` Overriding locally for this Z2H project (your other repos are unaffected).`);
109
165
  }
110
166
  async function ensureGitignore(dir) {
111
167
  const gitignorePath = path__default.default.join(dir, '.gitignore');
@@ -127,11 +183,12 @@ async function ensureGitignore(dir) {
127
183
 
128
184
  exports.GitConflictError = GitConflictError;
129
185
  exports.cloneRepo = cloneRepo;
130
- exports.commitDetailedAndPush = commitDetailedAndPush;
131
186
  exports.defaultS3Remote = defaultS3Remote;
132
187
  exports.getRemoteUrl = getRemoteUrl;
188
+ exports.guardCleanTree = guardCleanTree;
133
189
  exports.initRepo = initRepo;
134
190
  exports.localHead = localHead;
135
191
  exports.pullAndResolve = pullAndResolve;
192
+ exports.pushToRemote = pushToRemote;
136
193
  exports.remoteHead = remoteHead;
137
194
  exports.safeBackupIfDirty = safeBackupIfDirty;
@@ -1,10 +1,5 @@
1
1
  import { S3Client } from '@aws-sdk/client-s3';
2
- /**
3
- * Initialize the per-process S3 client with broker-issued credentials scoped
4
- * to the given app's S3 prefix. Must be called once before any `getS3Client`.
5
- */
6
- export declare function setupS3Client({ appName }: {
7
- appName: string;
8
- }): void;
2
+ import type { AwsCredentialIdentity } from '@aws-sdk/types';
3
+ export declare function setupS3Client(credentials: AwsCredentialIdentity): void;
9
4
  export declare function getS3Client(): S3Client;
10
5
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/util/s3/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAM9C;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKpE;AAED,wBAAgB,WAAW,IAAI,QAAQ,CAKtC"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/util/s3/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAE9C,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAI5D,wBAAgB,aAAa,CAAC,WAAW,EAAE,qBAAqB,GAAG,IAAI,CAEtE;AAED,wBAAgB,WAAW,IAAI,QAAQ,CAKtC"}
@@ -1,23 +1,15 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
2
 
3
3
  const clientS3 = require('@aws-sdk/client-s3');
4
- const util_auth_brokerCredentialProvider = require('../auth/broker-credential-provider.js');
5
4
  const constants = require('../../constants.js');
6
5
 
7
6
  let client = null;
8
- /**
9
- * Initialize the per-process S3 client with broker-issued credentials scoped
10
- * to the given app's S3 prefix. Must be called once before any `getS3Client`.
11
- */
12
- function setupS3Client({ appName }) {
13
- client = new clientS3.S3Client({
14
- region: constants.REGION,
15
- credentials: util_auth_brokerCredentialProvider.brokerCredentialProvider({ appName }),
16
- });
7
+ function setupS3Client(credentials) {
8
+ client = new clientS3.S3Client({ region: constants.REGION, credentials });
17
9
  }
18
10
  function getS3Client() {
19
11
  if (!client) {
20
- throw new Error('S3 client not initialized — call setupS3Client({ appName }) first.');
12
+ throw new Error('S3 client not initialized — call setupS3Client(credentials) first.');
21
13
  }
22
14
  return client;
23
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -5,9 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { runYarnInstall } from '../../util/npm/install';
7
7
  import { initRepo, defaultS3Remote } from '../../util/git/repo';
8
- import { buildCommand } from '../build';
8
+ import { prepareShadow } from '../../shadow/prepare';
9
9
  import { createCommand } from '../create';
10
- import * as manifest from '../../util/manifest';
11
10
 
12
11
  // Mock the install helper so `yarn install` (with its broker preflight) doesn't
13
12
  // actually run. Token-injection + 401 re-auth is covered in
@@ -21,12 +20,15 @@ vi.mock('../../util/git/repo', () => ({
21
20
  defaultS3Remote: vi.fn((app: string) => `s3://prod-use1-bigbrain-zth-mf-assets/mf-${app}/git`),
22
21
  }));
23
22
 
24
- // Mock build so tsc/yarn build don't run in tests.
25
- vi.mock('../build', () => ({
26
- buildCommand: vi.fn().mockResolvedValue(undefined),
23
+ // Mock shadow prep so the fs scaffolding doesn't run in tests.
24
+ vi.mock('../../shadow/prepare', () => ({
25
+ prepareShadow: vi.fn().mockResolvedValue(undefined),
27
26
  }));
28
27
 
29
- vi.spyOn(manifest, 'readManifest').mockResolvedValue({});
28
+ // Mock broker app lookup — no token needed in tests; default to null (app does not exist).
29
+ vi.mock('../../util/broker/app', () => ({
30
+ fetchApp: vi.fn().mockResolvedValue(null),
31
+ }));
30
32
 
31
33
  async function makeWorkspace(tmp: string): Promise<void> {
32
34
  await ensureDir(tmp);
@@ -47,7 +49,7 @@ describe('create', () => {
47
49
  vi.mocked(runYarnInstall).mockClear();
48
50
  vi.mocked(initRepo).mockClear();
49
51
  vi.mocked(defaultS3Remote).mockClear();
50
- vi.mocked(buildCommand).mockClear();
52
+ vi.mocked(prepareShadow).mockClear();
51
53
  });
52
54
 
53
55
  afterEach(async () => {
@@ -0,0 +1,184 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { mkdtemp, rm, ensureDir, writeJson } from 'fs-extra';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { runYarnInstall } from '../../util/npm/install';
7
+ import { prepareShadow } from '../../shadow/prepare';
8
+ import { editCommand } from '../edit';
9
+ import * as brokerApp from '../../util/broker/app';
10
+ import * as repo from '../../util/git/repo';
11
+ import * as brokerProvider from '../../util/auth/broker-credential-provider';
12
+
13
+ vi.mock('../../util/npm/install', () => ({
14
+ runYarnInstall: vi.fn().mockResolvedValue(undefined),
15
+ }));
16
+
17
+ vi.mock('../../shadow/prepare', () => ({
18
+ prepareShadow: vi.fn().mockResolvedValue(undefined),
19
+ }));
20
+
21
+ vi.mock('../../util/git/repo', () => ({
22
+ cloneRepo: vi.fn().mockResolvedValue(undefined),
23
+ pullAndResolve: vi.fn().mockResolvedValue(undefined),
24
+ GitConflictError: class GitConflictError extends Error {},
25
+ }));
26
+
27
+ const FAKE_CREDS = { accessKeyId: 'AKID', secretAccessKey: 'SECRET', sessionToken: 'TOKEN' };
28
+ const FAKE_GIT_ENV = { AWS_ACCESS_KEY_ID: 'AKID', AWS_SECRET_ACCESS_KEY: 'SECRET', AWS_SESSION_TOKEN: 'TOKEN' };
29
+
30
+ vi.mock('../../util/auth/broker-credential-provider', () => ({
31
+ brokerCredentialProvider: vi.fn(() => vi.fn().mockResolvedValue(FAKE_CREDS)),
32
+ }));
33
+
34
+ const S3_REMOTE = 's3://prod-use1-bigbrain-zth-mf-assets/mf-demo/git';
35
+ const GITHUB_REMOTE = 'https://github.com/org/demo-app.git';
36
+
37
+ async function makeWorkspace(tmp: string): Promise<void> {
38
+ await ensureDir(tmp);
39
+ await writeJson(path.join(tmp, 'package.json'), {
40
+ name: '@mondaydotcomorg/zth-projects-workspace',
41
+ private: true,
42
+ z2hWorkspace: true,
43
+ workspaces: ['*', '*/.zth'],
44
+ });
45
+ }
46
+
47
+ describe('edit', () => {
48
+ let tmp: string;
49
+
50
+ beforeEach(async () => {
51
+ tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-e-'));
52
+ vi.stubEnv('Z2H_WORKSPACE_HOME', tmp);
53
+ vi.mocked(runYarnInstall).mockClear();
54
+ vi.mocked(prepareShadow).mockClear();
55
+ vi.mocked(repo.cloneRepo).mockClear();
56
+ vi.mocked(repo.pullAndResolve).mockClear();
57
+ vi.mocked(brokerProvider.brokerCredentialProvider).mockClear();
58
+ });
59
+
60
+ afterEach(async () => {
61
+ vi.unstubAllEnvs();
62
+ await rm(tmp, { recursive: true, force: true });
63
+ });
64
+
65
+ it('skips cleanly when the app has no gitRemote', async () => {
66
+ await makeWorkspace(tmp);
67
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue(null);
68
+
69
+ await expect(editCommand('demo')).resolves.toBeUndefined();
70
+ expect(repo.cloneRepo).not.toHaveBeenCalled();
71
+ expect(repo.pullAndResolve).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('clones when the app dir does not exist locally', async () => {
75
+ await makeWorkspace(tmp);
76
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
77
+ appName: 'mf-demo',
78
+ version: 3,
79
+ gitRemote: S3_REMOTE,
80
+ entry: {},
81
+ deployedBy: 'u@x.com',
82
+ createdBy: 'u@x.com',
83
+ });
84
+
85
+ await editCommand('demo');
86
+
87
+ expect(repo.cloneRepo).toHaveBeenCalledWith(
88
+ S3_REMOTE,
89
+ path.join(tmp, 'demo'),
90
+ expect.objectContaining(FAKE_GIT_ENV)
91
+ );
92
+ expect(repo.pullAndResolve).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('pulls when the app dir already exists locally', async () => {
96
+ await makeWorkspace(tmp);
97
+ await ensureDir(path.join(tmp, 'demo'));
98
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
99
+ appName: 'mf-demo',
100
+ version: 3,
101
+ gitRemote: S3_REMOTE,
102
+ entry: {},
103
+ deployedBy: 'u@x.com',
104
+ createdBy: 'u@x.com',
105
+ });
106
+
107
+ await editCommand('demo');
108
+
109
+ expect(repo.pullAndResolve).toHaveBeenCalledWith(path.join(tmp, 'demo'), expect.objectContaining(FAKE_GIT_ENV));
110
+ expect(repo.cloneRepo).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('injects AWS credentials for S3 remotes', async () => {
114
+ await makeWorkspace(tmp);
115
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
116
+ appName: 'mf-demo',
117
+ version: 3,
118
+ gitRemote: S3_REMOTE,
119
+ entry: {},
120
+ deployedBy: 'u@x.com',
121
+ createdBy: 'u@x.com',
122
+ });
123
+
124
+ await editCommand('demo');
125
+
126
+ expect(brokerProvider.brokerCredentialProvider).toHaveBeenCalledWith({ appName: 'mf-demo' });
127
+ expect(repo.cloneRepo).toHaveBeenCalledWith(S3_REMOTE, expect.any(String), expect.objectContaining(FAKE_GIT_ENV));
128
+ });
129
+
130
+ it('does not inject AWS credentials for GitHub remotes', async () => {
131
+ await makeWorkspace(tmp);
132
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
133
+ appName: 'mf-demo',
134
+ version: 3,
135
+ gitRemote: GITHUB_REMOTE,
136
+ entry: {},
137
+ deployedBy: 'u@x.com',
138
+ createdBy: 'u@x.com',
139
+ });
140
+
141
+ await editCommand('demo');
142
+
143
+ expect(brokerProvider.brokerCredentialProvider).not.toHaveBeenCalled();
144
+ expect(repo.cloneRepo).toHaveBeenCalledWith(GITHUB_REMOTE, expect.any(String), undefined);
145
+ });
146
+
147
+ it('re-wires the workspace after a fresh clone', async () => {
148
+ await makeWorkspace(tmp);
149
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
150
+ appName: 'mf-demo',
151
+ version: 3,
152
+ gitRemote: S3_REMOTE,
153
+ entry: {},
154
+ deployedBy: 'u@x.com',
155
+ createdBy: 'u@x.com',
156
+ });
157
+ vi.mocked(repo.cloneRepo).mockImplementationOnce(async (_remote, dir) => {
158
+ await ensureDir(dir);
159
+ });
160
+
161
+ await editCommand('demo');
162
+
163
+ expect(runYarnInstall).toHaveBeenCalledWith(expect.objectContaining({ cwd: tmp }));
164
+ expect(prepareShadow).toHaveBeenCalledWith(path.join(tmp, 'demo'));
165
+ });
166
+
167
+ it('does not re-wire the workspace on a pull', async () => {
168
+ await makeWorkspace(tmp);
169
+ await ensureDir(path.join(tmp, 'demo'));
170
+ vi.spyOn(brokerApp, 'fetchApp').mockResolvedValue({
171
+ appName: 'mf-demo',
172
+ version: 3,
173
+ gitRemote: S3_REMOTE,
174
+ entry: {},
175
+ deployedBy: 'u@x.com',
176
+ createdBy: 'u@x.com',
177
+ });
178
+
179
+ await editCommand('demo');
180
+
181
+ expect(runYarnInstall).not.toHaveBeenCalled();
182
+ expect(prepareShadow).not.toHaveBeenCalled();
183
+ });
184
+ });