@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.
- package/dist/commands/__tests__/edit.test.d.ts +2 -0
- package/dist/commands/__tests__/edit.test.d.ts.map +1 -0
- package/dist/commands/create.d.ts +2 -2
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +14 -34
- package/dist/commands/deploy.d.ts +3 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +120 -21
- package/dist/commands/edit.d.ts +5 -0
- package/dist/commands/edit.d.ts.map +1 -0
- package/dist/commands/edit.js +61 -0
- package/dist/commands/grant.d.ts +12 -0
- package/dist/commands/grant.d.ts.map +1 -0
- package/dist/commands/grant.js +94 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +7 -0
- package/dist/esm/commands/__tests__/edit.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/edit.test.d.ts.map +1 -0
- package/dist/esm/commands/create.d.ts +2 -2
- package/dist/esm/commands/create.d.ts.map +1 -1
- package/dist/esm/commands/create.mjs +16 -35
- package/dist/esm/commands/deploy.d.ts +3 -0
- package/dist/esm/commands/deploy.d.ts.map +1 -1
- package/dist/esm/commands/deploy.mjs +122 -23
- package/dist/esm/commands/edit.d.ts +5 -0
- package/dist/esm/commands/edit.d.ts.map +1 -0
- package/dist/esm/commands/edit.mjs +55 -0
- package/dist/esm/commands/grant.d.ts +12 -0
- package/dist/esm/commands/grant.d.ts.map +1 -0
- package/dist/esm/commands/grant.mjs +90 -0
- package/dist/esm/constants.d.ts +1 -0
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.mjs +7 -1
- package/dist/esm/index.mjs +67 -10
- package/dist/esm/shadow/prepare.d.ts +1 -1
- package/dist/esm/shadow/prepare.d.ts.map +1 -1
- package/dist/esm/shadow/prepare.mjs +2 -2
- package/dist/esm/util/app-name.d.ts +1 -0
- package/dist/esm/util/app-name.d.ts.map +1 -1
- package/dist/esm/util/app-name.mjs +11 -1
- package/dist/esm/util/auth/broker-credential-provider.d.ts +10 -2
- package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
- package/dist/esm/util/auth/broker-credential-provider.mjs +35 -13
- package/dist/esm/util/broker/app.d.ts +14 -0
- package/dist/esm/util/broker/app.d.ts.map +1 -0
- package/dist/esm/util/broker/app.mjs +27 -0
- package/dist/esm/util/git/git-env.d.ts +9 -0
- package/dist/esm/util/git/git-env.d.ts.map +1 -0
- package/dist/esm/util/git/git-env.mjs +22 -0
- package/dist/esm/util/git/remote-options.d.ts +17 -0
- package/dist/esm/util/git/remote-options.d.ts.map +1 -0
- package/dist/esm/util/git/remote-options.mjs +30 -0
- package/dist/esm/util/git/repo.d.ts +21 -3
- package/dist/esm/util/git/repo.d.ts.map +1 -1
- package/dist/esm/util/git/repo.mjs +103 -47
- package/dist/esm/util/s3/client.d.ts +2 -7
- package/dist/esm/util/s3/client.d.ts.map +1 -1
- package/dist/esm/util/s3/client.mjs +3 -11
- package/dist/index.js +69 -12
- package/dist/shadow/prepare.d.ts +1 -1
- package/dist/shadow/prepare.d.ts.map +1 -1
- package/dist/shadow/prepare.js +2 -2
- package/dist/util/app-name.d.ts +1 -0
- package/dist/util/app-name.d.ts.map +1 -1
- package/dist/util/app-name.js +11 -0
- package/dist/util/auth/broker-credential-provider.d.ts +10 -2
- package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
- package/dist/util/auth/broker-credential-provider.js +35 -11
- package/dist/util/broker/app.d.ts +14 -0
- package/dist/util/broker/app.d.ts.map +1 -0
- package/dist/util/broker/app.js +29 -0
- package/dist/util/git/git-env.d.ts +9 -0
- package/dist/util/git/git-env.d.ts.map +1 -0
- package/dist/util/git/git-env.js +24 -0
- package/dist/util/git/remote-options.d.ts +17 -0
- package/dist/util/git/remote-options.d.ts.map +1 -0
- package/dist/util/git/remote-options.js +34 -0
- package/dist/util/git/repo.d.ts +21 -3
- package/dist/util/git/repo.d.ts.map +1 -1
- package/dist/util/git/repo.js +104 -47
- package/dist/util/s3/client.d.ts +2 -7
- package/dist/util/s3/client.d.ts.map +1 -1
- package/dist/util/s3/client.js +3 -11
- package/package.json +1 -1
- package/src/commands/__tests__/create.test.ts +9 -7
- package/src/commands/__tests__/edit.test.ts +184 -0
- package/src/commands/create.ts +15 -33
- package/src/commands/deploy.ts +145 -31
- package/src/commands/edit.ts +63 -0
- package/src/commands/grant.ts +115 -0
- package/src/constants.ts +7 -0
- package/src/index.ts +83 -13
- package/src/shadow/prepare.ts +2 -2
- package/src/templates/app/CLAUDE.md +15 -0
- package/src/util/app-name.ts +10 -0
- package/src/util/auth/__tests__/broker-credential-provider.test.ts +34 -2
- package/src/util/auth/broker-credential-provider.ts +43 -15
- package/src/util/broker/app.ts +34 -0
- package/src/util/git/__tests__/repo.test.ts +108 -90
- package/src/util/git/git-env.ts +23 -0
- package/src/util/git/remote-options.ts +37 -0
- package/src/util/git/repo.ts +121 -47
- package/src/util/s3/client.ts +4 -11
package/src/index.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
2
|
import os from 'node:os';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import { Command } from 'commander';
|
|
6
5
|
import { execa } from 'execa';
|
|
6
|
+
import { getWorkspaceHome } from './util/workspace';
|
|
7
7
|
import { devCommand } from './commands/dev';
|
|
8
8
|
import { buildCommand } from './commands/build';
|
|
9
9
|
import { cleanCommand } from './commands/clean';
|
|
10
10
|
import { deployCommand } from './commands/deploy';
|
|
11
|
+
import { grantCommand, revokeCommand, transferOwnerCommand } from './commands/grant';
|
|
11
12
|
import { createWorkspaceCommand } from './commands/create-workspace';
|
|
12
13
|
import { createCommand, CreateOptions } from './commands/create';
|
|
13
14
|
import { generateZ2HTokenCommand } from './commands/generate-z2h-token';
|
|
14
15
|
import { migrateCommand } from './commands/migrate';
|
|
16
|
+
import { editCommand } from './commands/edit';
|
|
15
17
|
import { errorLog, fail, getOutputMode } from './util/logger';
|
|
16
18
|
import { trackDuration } from './util/tracker';
|
|
19
|
+
import { getCwdAppName } from './util/app-name';
|
|
17
20
|
|
|
18
21
|
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
|
|
19
22
|
|
|
@@ -30,15 +33,17 @@ function selfUpdate(): void {
|
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const CONFLICTING_ENV_VARS = ['AWS_PROFILE'];
|
|
37
|
+
|
|
38
|
+
function stripConflictingEnvVars(): void {
|
|
39
|
+
for (const name of CONFLICTING_ENV_VARS) {
|
|
40
|
+
delete process.env[name];
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
async function main(): Promise<void> {
|
|
45
|
+
stripConflictingEnvVars();
|
|
46
|
+
|
|
42
47
|
const program = new Command();
|
|
43
48
|
program
|
|
44
49
|
.name('z2h-cli')
|
|
@@ -96,14 +101,26 @@ async function main(): Promise<void> {
|
|
|
96
101
|
|
|
97
102
|
program
|
|
98
103
|
.command('deploy')
|
|
99
|
-
.description('Build, upload
|
|
104
|
+
.description('Build, upload to S3, and register the app with the Z2H backend')
|
|
100
105
|
.option('--dry-run', 'print what would happen without uploading anything')
|
|
101
106
|
.option('--json', 'emit structured JSON on stdout')
|
|
102
|
-
.
|
|
107
|
+
.option(
|
|
108
|
+
'--remote <type>',
|
|
109
|
+
'First deploy only: source control remote to initialise — s3 (shared storage) or github (your repo). Error if the app already has a git remote.'
|
|
110
|
+
)
|
|
111
|
+
.option(
|
|
112
|
+
'--repo-url <url>',
|
|
113
|
+
'First deploy only: GitHub repo URL — required when --remote=github. Error if the app already has a git remote.'
|
|
114
|
+
)
|
|
115
|
+
.action(async (opts: { dryRun?: boolean; json?: boolean; remote?: string; repoUrl?: string }) => {
|
|
103
116
|
try {
|
|
104
|
-
await trackDuration(
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
await trackDuration(
|
|
118
|
+
'z2h_app_deploy_success',
|
|
119
|
+
() => deployCommand(opts as Parameters<typeof deployCommand>[0]),
|
|
120
|
+
{
|
|
121
|
+
app_name: getCwdAppName(),
|
|
122
|
+
}
|
|
123
|
+
);
|
|
107
124
|
} catch (err) {
|
|
108
125
|
await fail(`deploy failed: ${(err as Error).message}`, { command: 'deploy' }, 2);
|
|
109
126
|
}
|
|
@@ -111,7 +128,7 @@ async function main(): Promise<void> {
|
|
|
111
128
|
|
|
112
129
|
program
|
|
113
130
|
.command('create-workspace')
|
|
114
|
-
.description(
|
|
131
|
+
.description(`Bootstrap the ${getWorkspaceHome()}/ yarn workspace (one-time setup)`)
|
|
115
132
|
.option('--json', 'emit structured JSON on stdout')
|
|
116
133
|
.action(async (opts: { json?: boolean }) => {
|
|
117
134
|
try {
|
|
@@ -139,7 +156,7 @@ async function main(): Promise<void> {
|
|
|
139
156
|
|
|
140
157
|
program
|
|
141
158
|
.command('create <app-name>')
|
|
142
|
-
.description(
|
|
159
|
+
.description(`Scaffold a new Z2H consumer app inside ${getWorkspaceHome()}/`)
|
|
143
160
|
.option('--template <name>', 'Template name (default | html-embed)')
|
|
144
161
|
.option('--html-file <path>', 'Path to an HTML file to embed (implies --template html-embed)')
|
|
145
162
|
.option('--remote <type>', 'Source control remote: s3 (shared storage) or github (your repo)')
|
|
@@ -162,6 +179,59 @@ async function main(): Promise<void> {
|
|
|
162
179
|
}
|
|
163
180
|
);
|
|
164
181
|
|
|
182
|
+
program
|
|
183
|
+
.command('edit <app-name>')
|
|
184
|
+
.description(`Pull the latest version of an app into ${getWorkspaceHome()}/<app-name>, cloning it first if needed`)
|
|
185
|
+
.option('--json', 'emit structured JSON on stdout')
|
|
186
|
+
.action(async (appName: string, opts: { json?: boolean }) => {
|
|
187
|
+
try {
|
|
188
|
+
await trackDuration('z2h_app_edit_pulled', () => editCommand(appName, opts), {
|
|
189
|
+
app_name: appName,
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
await fail(`edit failed: ${(err as Error).message}`, { command: 'edit' }, 1);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
program
|
|
197
|
+
.command('grant [email]')
|
|
198
|
+
.description('Grant access to the current app (email, --public, or both)')
|
|
199
|
+
.option('--public', 'make the app visible to all monday.com employees')
|
|
200
|
+
.option('--level <level>', 'access level for user grants: owner, editor, or viewer (default: viewer)')
|
|
201
|
+
.option('--json', 'emit structured JSON on stdout')
|
|
202
|
+
.action(async (email: string | undefined, opts: { public?: boolean; level?: string; json?: boolean }) => {
|
|
203
|
+
try {
|
|
204
|
+
await grantCommand(email, opts as Parameters<typeof grantCommand>[1]);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
await fail(`grant failed: ${(err as Error).message}`, { command: 'grant' }, 1);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
program
|
|
211
|
+
.command('revoke [email]')
|
|
212
|
+
.description('Revoke access to the current app (email or --public; owners/admins only)')
|
|
213
|
+
.option('--public', 'remove the public grant, making the app visible to owners/admins only')
|
|
214
|
+
.option('--json', 'emit structured JSON on stdout')
|
|
215
|
+
.action(async (email: string | undefined, opts: { public?: boolean; json?: boolean }) => {
|
|
216
|
+
try {
|
|
217
|
+
await revokeCommand(email, opts as Parameters<typeof revokeCommand>[1]);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
await fail(`revoke failed: ${(err as Error).message}`, { command: 'revoke' }, 1);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
program
|
|
224
|
+
.command('transfer-owner [email]')
|
|
225
|
+
.description('Transfer ownership of the current app to another monday.com user')
|
|
226
|
+
.option('--json', 'emit structured JSON on stdout')
|
|
227
|
+
.action(async (email: string | undefined, opts: { json?: boolean }) => {
|
|
228
|
+
try {
|
|
229
|
+
await transferOwnerCommand(email, opts);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
await fail(`transfer-owner failed: ${(err as Error).message}`, { command: 'transfer-owner' }, 1);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
165
235
|
program
|
|
166
236
|
.command('migrate')
|
|
167
237
|
.description('Run pending workspace migrations')
|
package/src/shadow/prepare.ts
CHANGED
|
@@ -10,8 +10,8 @@ import { isPortFree, pickPort } from './port';
|
|
|
10
10
|
import { syncShadow } from './sync';
|
|
11
11
|
import type { ConsumerPaths, ConsumerPkg, PreparedShadow, ShadowMetadata } from '../types';
|
|
12
12
|
|
|
13
|
-
export async function prepareShadow(): Promise<PreparedShadow> {
|
|
14
|
-
const paths = resolveConsumerPaths(
|
|
13
|
+
export async function prepareShadow(consumerDir: string = process.cwd()): Promise<PreparedShadow> {
|
|
14
|
+
const paths = resolveConsumerPaths(consumerDir);
|
|
15
15
|
|
|
16
16
|
if (!(await pathExists(paths.consumerPkg))) {
|
|
17
17
|
throw new Error(`No package.json found in ${paths.consumerDir}`);
|
|
@@ -45,6 +45,21 @@ When a request needs data, follow `z2h:coding-standards-mf` §0: write a `runQue
|
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
+
## App Visibility
|
|
49
|
+
|
|
50
|
+
Apps are **private by default** — only you can view them. After the first deploy the `z2h:feature-development` skill automatically asks whether to open it up:
|
|
51
|
+
|
|
52
|
+
> "Should this app be visible to everyone at monday.com, or just you for now?"
|
|
53
|
+
|
|
54
|
+
To change visibility at any time:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
npx z2h-cli grant --public # open to all monday.com employees
|
|
58
|
+
npx z2h-cli revoke --public # back to private
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
48
63
|
## ⛔ No Agent Visual Testing
|
|
49
64
|
|
|
50
65
|
When you make a change, **do not screenshot, do not run `axcli chrome`, do not run `playwright-cli`, do not open browser tabs**. The user is sitting in front of the live preview and verifies with their own eyes — HMR updates instantly. Edit → tell the user → wait for their approval. Agent-side visual checks have proven slow and unreliable in this flow.
|
package/src/util/app-name.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Strip any npm scope (`@scope/`) from a package name.
|
|
3
5
|
* e.g. `@trident/customer-viewer` → `customer-viewer`, `foo` → `foo`.
|
|
@@ -6,6 +8,14 @@ export function cleanAppName(name: string): string {
|
|
|
6
8
|
return name.replace(/^@[^/]+\//, '');
|
|
7
9
|
}
|
|
8
10
|
|
|
11
|
+
export function getCwdAppName(): string | undefined {
|
|
12
|
+
try {
|
|
13
|
+
return (JSON.parse(readFileSync('package.json', 'utf8')) as { name?: string }).name;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
/**
|
|
10
20
|
* Canonical MF bundle name: the de-scoped app name with an `mf-` prefix.
|
|
11
21
|
* This matches the trident convention (shadow pkg is `@trident/mf-<app>`,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
brokerCredentialProvider,
|
|
4
|
+
fetchDeployVersion,
|
|
5
|
+
DEPLOY_VERSION_PATH,
|
|
6
|
+
STS_BROKER_PATH,
|
|
7
|
+
} from '../broker-credential-provider';
|
|
3
8
|
import { DEFAULT_BROKER_BASE_URL } from '../../../constants';
|
|
4
9
|
import * as authStore from '../auth-store';
|
|
5
10
|
|
|
@@ -32,8 +37,35 @@ function mockFetchOnce(body: unknown, init: { status?: number; text?: string } =
|
|
|
32
37
|
return fetchMock;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
describe('fetchDeployVersion', () => {
|
|
41
|
+
it('POSTs the appName to the version endpoint and returns the version', async () => {
|
|
42
|
+
const fetchMock = mockFetchOnce({ version: 4 });
|
|
43
|
+
|
|
44
|
+
const version = await fetchDeployVersion('my-app');
|
|
45
|
+
|
|
46
|
+
expect(version).toBe(4);
|
|
47
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
48
|
+
`${DEFAULT_BROKER_BASE_URL}${DEPLOY_VERSION_PATH}`,
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
method: 'POST',
|
|
51
|
+
body: JSON.stringify({ appName: 'my-app' }),
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns 0 when the broker returns no version field (new app)', async () => {
|
|
57
|
+
mockFetchOnce({});
|
|
58
|
+
await expect(fetchDeployVersion('my-app')).resolves.toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('surfaces a remediation hint on 401', async () => {
|
|
62
|
+
mockFetchOnce({}, { status: 401, text: 'Unauthorized' });
|
|
63
|
+
await expect(fetchDeployVersion('my-app')).rejects.toThrow(/token rejected by the broker/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
35
67
|
describe('brokerCredentialProvider', () => {
|
|
36
|
-
it('POSTs the appName to the broker with a Bearer token + parses the response', async () => {
|
|
68
|
+
it('POSTs the appName + version to the broker with a Bearer token + parses the response', async () => {
|
|
37
69
|
const fetchMock = mockFetchOnce({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY });
|
|
38
70
|
const provider = brokerCredentialProvider({ appName: 'my-app' });
|
|
39
71
|
const creds = await provider();
|
|
@@ -1,51 +1,78 @@
|
|
|
1
|
-
import { brokerBaseUrl } from '../../constants';
|
|
1
|
+
import { brokerBaseUrl, routingHeaders } from '../../constants';
|
|
2
2
|
import { readZthAuth } from './auth-store';
|
|
3
3
|
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@aws-sdk/types';
|
|
4
4
|
|
|
5
5
|
export const STS_BROKER_PATH = '/z2h-cli/sts/credentials';
|
|
6
|
+
export const DEPLOY_VERSION_PATH = '/z2h-cli/apps/version';
|
|
6
7
|
|
|
7
8
|
// Refresh the cached credentials this many ms before they expire so the next
|
|
8
9
|
// AWS API call doesn't race the broker round-trip against a hard expiry.
|
|
9
10
|
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
10
11
|
|
|
11
|
-
interface
|
|
12
|
+
interface CredentialsResponse {
|
|
12
13
|
accessKeyId?: string;
|
|
13
14
|
secretAccessKey?: string;
|
|
14
15
|
sessionToken?: string;
|
|
15
16
|
expiration?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
interface VersionResponse {
|
|
20
|
+
version?: number;
|
|
21
|
+
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
async function postToBroker(path: string, body: unknown): Promise<Response> {
|
|
24
|
+
const { token } = await readZthAuth();
|
|
23
25
|
try {
|
|
24
|
-
|
|
26
|
+
return await fetch(`${brokerBaseUrl()}${path}`, {
|
|
25
27
|
method: 'POST',
|
|
26
28
|
headers: {
|
|
27
29
|
Authorization: `Bearer ${token}`,
|
|
28
30
|
'Content-Type': 'application/json',
|
|
29
31
|
Accept: 'application/json',
|
|
32
|
+
...routingHeaders(),
|
|
30
33
|
},
|
|
31
|
-
body: JSON.stringify(
|
|
34
|
+
body: JSON.stringify(body),
|
|
32
35
|
});
|
|
33
36
|
} catch (err) {
|
|
34
37
|
const message = err instanceof Error ? err.message : String(err);
|
|
35
38
|
throw new Error(`Z2H deploy broker request failed: ${message}`);
|
|
36
39
|
}
|
|
40
|
+
}
|
|
37
41
|
|
|
42
|
+
function assertBrokerOk(res: Response): void {
|
|
38
43
|
if (res.status === 401 || res.status === 403) {
|
|
39
44
|
throw new Error(
|
|
40
45
|
`Z2H deploy token rejected by the broker (${res.status}). Refresh user token by running 'z2h-cli generate-z2h-token'.`
|
|
41
46
|
);
|
|
42
47
|
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reserve the version this deploy will write to. Split out of the STS
|
|
52
|
+
* credentials call so version derivation lives on its own endpoint — the CLI
|
|
53
|
+
* asks for the version first, then requests credentials scoped to it.
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchDeployVersion(appName: string): Promise<number> {
|
|
56
|
+
const res = await postToBroker(DEPLOY_VERSION_PATH, { appName });
|
|
57
|
+
assertBrokerOk(res);
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const text = await res.text();
|
|
60
|
+
throw new Error(`Z2H deploy broker returned ${res.status}: ${text.slice(0, 1024)}`);
|
|
61
|
+
}
|
|
62
|
+
const body = (await res.json()) as VersionResponse;
|
|
63
|
+
|
|
64
|
+
return body.version ?? 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fetchStsOnce(appName: string): Promise<AwsCredentialIdentity> {
|
|
68
|
+
const res = await postToBroker(STS_BROKER_PATH, { appName });
|
|
69
|
+
assertBrokerOk(res);
|
|
43
70
|
if (!res.ok) {
|
|
44
71
|
const text = await res.text();
|
|
45
72
|
throw new Error(`Z2H deploy broker returned ${res.status}: ${text.slice(0, 1024)}`);
|
|
46
73
|
}
|
|
47
74
|
|
|
48
|
-
const body = (await res.json()) as
|
|
75
|
+
const body = (await res.json()) as CredentialsResponse;
|
|
49
76
|
if (!body.accessKeyId || !body.secretAccessKey || !body.sessionToken || !body.expiration) {
|
|
50
77
|
throw new Error('Z2H deploy broker response missing required credential fields');
|
|
51
78
|
}
|
|
@@ -60,8 +87,9 @@ async function fetchCredentialsOnce(appName: string): Promise<AwsCredentialIdent
|
|
|
60
87
|
|
|
61
88
|
/**
|
|
62
89
|
* AWS SDK credential provider that fetches short-lived deploy credentials
|
|
63
|
-
* from the bigbrain-zth STS broker
|
|
64
|
-
* 5 minutes before expiry; concurrent calls share a
|
|
90
|
+
* from the bigbrain-zth STS broker for a specific version. Credentials are
|
|
91
|
+
* cached in memory until 5 minutes before expiry; concurrent calls share a
|
|
92
|
+
* single in-flight fetch.
|
|
65
93
|
*/
|
|
66
94
|
export function brokerCredentialProvider({ appName }: { appName: string }): AwsCredentialIdentityProvider {
|
|
67
95
|
let cached: AwsCredentialIdentity | undefined;
|
|
@@ -76,10 +104,10 @@ export function brokerCredentialProvider({ appName }: { appName: string }): AwsC
|
|
|
76
104
|
return inflight;
|
|
77
105
|
}
|
|
78
106
|
|
|
79
|
-
inflight =
|
|
80
|
-
.then(
|
|
81
|
-
cached =
|
|
82
|
-
return
|
|
107
|
+
inflight = fetchStsOnce(appName)
|
|
108
|
+
.then(credentials => {
|
|
109
|
+
cached = credentials;
|
|
110
|
+
return credentials;
|
|
83
111
|
})
|
|
84
112
|
.finally(() => {
|
|
85
113
|
inflight = undefined;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { brokerBaseUrl, routingHeaders } from '../../constants';
|
|
2
|
+
import { readZthAuth } from '../auth/auth-store';
|
|
3
|
+
|
|
4
|
+
export interface AppInfo {
|
|
5
|
+
appName: string;
|
|
6
|
+
version: number;
|
|
7
|
+
entry: Record<string, unknown>;
|
|
8
|
+
deployedBy: string;
|
|
9
|
+
gitRemote: string;
|
|
10
|
+
createdBy: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetches a single app's current state from the broker.
|
|
15
|
+
* Returns null if the app has never been deployed (404).
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchApp(appName: string): Promise<AppInfo | null> {
|
|
18
|
+
const { token } = await readZthAuth();
|
|
19
|
+
const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/${encodeURIComponent(appName)}`, {
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
Accept: 'application/json',
|
|
23
|
+
...routingHeaders(),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
if (res.status === 404) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
throw new Error(`Z2H app lookup failed (${res.status}): ${text.slice(0, 512)}`);
|
|
32
|
+
}
|
|
33
|
+
return res.json() as Promise<AppInfo>;
|
|
34
|
+
}
|
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
+
}
|