@mondaydotcomorg/z2h-cli 0.15.2 → 0.16.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__/deploy.test.d.ts +2 -0
- package/dist/commands/__tests__/deploy.test.d.ts.map +1 -0
- package/dist/commands/build.d.ts +8 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +12 -0
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +1 -12
- package/dist/commands/deploy.d.ts +3 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +69 -72
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +1 -11
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +10 -5
- package/dist/esm/commands/__tests__/deploy.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/deploy.test.d.ts.map +1 -0
- package/dist/esm/commands/build.d.ts +8 -1
- package/dist/esm/commands/build.d.ts.map +1 -1
- package/dist/esm/commands/build.mjs +9 -1
- package/dist/esm/commands/create.d.ts.map +1 -1
- package/dist/esm/commands/create.mjs +2 -13
- 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 +72 -74
- package/dist/esm/commands/doctor.d.ts.map +1 -1
- package/dist/esm/commands/doctor.mjs +1 -11
- package/dist/esm/constants.d.ts +2 -0
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.mjs +9 -6
- package/dist/esm/index.mjs +9 -15
- package/dist/esm/migrations/006-remove-start-script.d.ts +3 -0
- package/dist/esm/migrations/006-remove-start-script.d.ts.map +1 -0
- package/dist/esm/migrations/006-remove-start-script.mjs +22 -0
- package/dist/esm/util/__tests__/app-name.test.d.ts +2 -0
- package/dist/esm/util/__tests__/app-name.test.d.ts.map +1 -0
- package/dist/esm/util/app-name.d.ts +17 -0
- package/dist/esm/util/app-name.d.ts.map +1 -1
- package/dist/esm/util/app-name.mjs +45 -1
- package/dist/esm/util/auth/broker-credential-provider.d.ts +7 -5
- package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
- package/dist/esm/util/auth/broker-credential-provider.mjs +28 -16
- package/dist/esm/util/broker/__tests__/app.test.d.ts +2 -0
- package/dist/esm/util/broker/__tests__/app.test.d.ts.map +1 -0
- package/dist/esm/util/broker/app.d.ts +15 -0
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/esm/util/broker/app.mjs +44 -1
- package/dist/esm/util/prompt-visibility.d.ts +2 -0
- package/dist/esm/util/prompt-visibility.d.ts.map +1 -0
- package/dist/esm/util/prompt-visibility.mjs +19 -0
- package/dist/index.js +9 -15
- package/dist/migrations/006-remove-start-script.d.ts +3 -0
- package/dist/migrations/006-remove-start-script.d.ts.map +1 -0
- package/dist/migrations/006-remove-start-script.js +24 -0
- package/dist/util/__tests__/app-name.test.d.ts +2 -0
- package/dist/util/__tests__/app-name.test.d.ts.map +1 -0
- package/dist/util/app-name.d.ts +17 -0
- package/dist/util/app-name.d.ts.map +1 -1
- package/dist/util/app-name.js +49 -2
- package/dist/util/auth/broker-credential-provider.d.ts +7 -5
- package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
- package/dist/util/auth/broker-credential-provider.js +28 -16
- package/dist/util/broker/__tests__/app.test.d.ts +2 -0
- package/dist/util/broker/__tests__/app.test.d.ts.map +1 -0
- package/dist/util/broker/app.d.ts +15 -0
- package/dist/util/broker/app.d.ts.map +1 -1
- package/dist/util/broker/app.js +44 -0
- package/dist/util/prompt-visibility.d.ts +2 -0
- package/dist/util/prompt-visibility.d.ts.map +1 -0
- package/dist/util/prompt-visibility.js +21 -0
- package/migrations.json +5 -0
- package/package.json +1 -1
- package/src/commands/__tests__/create.test.ts +5 -0
- package/src/commands/__tests__/deploy.test.ts +215 -0
- package/src/commands/__tests__/doctor.test.ts +0 -12
- package/src/commands/build.ts +20 -2
- package/src/commands/create.ts +2 -18
- package/src/commands/deploy.ts +113 -93
- package/src/commands/doctor.ts +1 -20
- package/src/constants.ts +10 -5
- package/src/index.ts +31 -25
- package/src/migrations/006-remove-start-script.ts +24 -0
- package/src/shadow/__tests__/merge-package-json.test.ts +0 -1
- package/src/templates/app/CLAUDE.md +3 -3
- package/src/templates/app/package.json.tmpl +0 -1
- package/src/templates/workspace/README.md +2 -2
- package/src/util/__tests__/app-name.test.ts +78 -0
- package/src/util/app-name.ts +55 -0
- package/src/util/auth/__tests__/broker-credential-provider.test.ts +53 -1
- package/src/util/auth/broker-credential-provider.ts +41 -16
- package/src/util/broker/__tests__/app.test.ts +104 -0
- package/src/util/broker/app.ts +68 -0
- package/src/util/prompt-visibility.ts +18 -0
- package/dist/commands/dev.d.ts +0 -5
- package/dist/commands/dev.d.ts.map +0 -1
- package/dist/commands/dev.js +0 -92
- package/dist/esm/commands/dev.d.ts +0 -5
- package/dist/esm/commands/dev.d.ts.map +0 -1
- package/dist/esm/commands/dev.mjs +0 -85
- package/src/commands/dev.ts +0 -93
package/src/constants.ts
CHANGED
|
@@ -8,7 +8,6 @@ export const SHADOW_DIR_NAME = '.zth';
|
|
|
8
8
|
|
|
9
9
|
// ─────────── Shadow scripts ───────
|
|
10
10
|
export const SHADOW_SCRIPTS: Record<string, string> = {
|
|
11
|
-
start: 'trident start',
|
|
12
11
|
build: 'trident build',
|
|
13
12
|
};
|
|
14
13
|
|
|
@@ -19,10 +18,10 @@ export const SHADOW_DEV_DEPS: Record<string, string> = {
|
|
|
19
18
|
'@mondaydotcomorg/trident-toolkit': '^2.9.25',
|
|
20
19
|
};
|
|
21
20
|
|
|
22
|
-
// ─────────── Auto-allocated
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
21
|
+
// ─────────── Auto-allocated shadow port range ────
|
|
22
|
+
// Baked into every shadow's .tridentrc.js regardless of whether a dev server
|
|
23
|
+
// ever binds it. High enough to dodge common dev defaults (3000 Next, 5173
|
|
24
|
+
// Vite, 8080). Collisions within the 7k-port pool are rare for POC.
|
|
26
25
|
export const MIN_PORT = 13000;
|
|
27
26
|
export const MAX_PORT = 19999;
|
|
28
27
|
|
|
@@ -42,6 +41,10 @@ export const BUCKET_WRITE_URL = `https://${BUCKET_WRITE_NAME}.s3.${REGION}.amazo
|
|
|
42
41
|
export const BUCKET_READ_DNS_NAME = 'bigbrain-zth-mf-assets';
|
|
43
42
|
export const BUCKET_READ_DNS_URL = `https://${BUCKET_READ_DNS_NAME}.bigbrain.me`;
|
|
44
43
|
|
|
44
|
+
// Root prefix for ephemeral preview uploads: `previews/<appName>/<previewId>/MF/...`.
|
|
45
|
+
// Shared by the CLI (upload path) and bigbrain-zth (STS policy + manifest overlay).
|
|
46
|
+
export const S3_PREVIEWS_FOLDER = 'previews';
|
|
47
|
+
|
|
45
48
|
// No `.json` extension — TridentSkeleton's `mfUrl` API appends `/mf-assets`
|
|
46
49
|
// to the bucket base when fetching, so the manifest must live at this exact
|
|
47
50
|
// key. See microfrontends/mf-bigbrain-zth/.../ZeroToHeroHost.tsx.
|
|
@@ -54,6 +57,8 @@ export const MANIFEST_READ_URL = `${BUCKET_READ_DNS_URL}/${MANIFEST_KEY}`;
|
|
|
54
57
|
// upfront for friendlier errors.
|
|
55
58
|
export const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{1,49}$/;
|
|
56
59
|
|
|
60
|
+
export const PREVIEW_APP_NAME_PREFIX = 'zth-preview-';
|
|
61
|
+
|
|
57
62
|
// ─────────── Broker origin + npm registry (shared §3 contract) ─────
|
|
58
63
|
// The bigbrain-zth broker fronts both the STS credential route and the npm
|
|
59
64
|
// proxy. `Z2H_BROKER_BASE_URL` overrides the origin for tests/staging.
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,6 @@ import path from 'node:path';
|
|
|
4
4
|
import { Command } from 'commander';
|
|
5
5
|
import { execa } from 'execa';
|
|
6
6
|
import { getWorkspaceHome } from './util/workspace';
|
|
7
|
-
import { devCommand } from './commands/dev';
|
|
8
7
|
import { buildCommand } from './commands/build';
|
|
9
8
|
import { cleanCommand } from './commands/clean';
|
|
10
9
|
import { deployCommand } from './commands/deploy';
|
|
@@ -88,18 +87,6 @@ async function main(): Promise<void> {
|
|
|
88
87
|
}
|
|
89
88
|
});
|
|
90
89
|
|
|
91
|
-
program
|
|
92
|
-
.command('dev')
|
|
93
|
-
.description('Start the Trident dev server against .zth/ with native Vite HMR')
|
|
94
|
-
.option('--open', 'open the browser automatically when the dev server is ready')
|
|
95
|
-
.action(async (opts: { open?: boolean }) => {
|
|
96
|
-
try {
|
|
97
|
-
await devCommand(opts);
|
|
98
|
-
} catch (err) {
|
|
99
|
-
await fail(`dev failed: ${(err as Error).message}`, { command: 'dev' }, 2);
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
|
|
103
90
|
program
|
|
104
91
|
.command('clean')
|
|
105
92
|
.description('Delete <consumer>/.zth/ and <consumer>/build/')
|
|
@@ -115,6 +102,11 @@ async function main(): Promise<void> {
|
|
|
115
102
|
.command('deploy')
|
|
116
103
|
.description('Build, upload to S3, and register the app with the Z2H backend')
|
|
117
104
|
.option('--dry-run', 'print what would happen without uploading anything')
|
|
105
|
+
.option(
|
|
106
|
+
'--preview [previewId]',
|
|
107
|
+
'build an ephemeral, shareable preview instead of deploying — no git required, no deploy lock, never touches the live version; pass an existing previewId to update it instead of minting a new one'
|
|
108
|
+
)
|
|
109
|
+
.option('--open', 'open the resulting app/preview URL in the browser on success')
|
|
118
110
|
.option(
|
|
119
111
|
'--remote <type>',
|
|
120
112
|
'First deploy only: source control remote to initialise — s3 (shared storage) or github (your repo). Error if the app already has a git remote.'
|
|
@@ -123,19 +115,33 @@ async function main(): Promise<void> {
|
|
|
123
115
|
'--repo-url <url>',
|
|
124
116
|
'First deploy only: GitHub repo URL — required when --remote=github. Error if the app already has a git remote.'
|
|
125
117
|
)
|
|
126
|
-
.action(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
118
|
+
.action(
|
|
119
|
+
async (opts: {
|
|
120
|
+
dryRun?: boolean;
|
|
121
|
+
preview?: boolean | string;
|
|
122
|
+
open?: boolean;
|
|
123
|
+
remote?: string;
|
|
124
|
+
repoUrl?: string;
|
|
125
|
+
}) => {
|
|
126
|
+
try {
|
|
127
|
+
await trackDuration(
|
|
128
|
+
opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success',
|
|
129
|
+
() =>
|
|
130
|
+
deployCommand({
|
|
131
|
+
...opts,
|
|
132
|
+
preview: Boolean(opts.preview),
|
|
133
|
+
previewId: typeof opts.preview === 'string' ? opts.preview : undefined,
|
|
134
|
+
} as Parameters<typeof deployCommand>[0]),
|
|
135
|
+
{
|
|
136
|
+
app_name: getCwdAppName(),
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const label = opts.preview ? 'preview' : 'deploy';
|
|
141
|
+
await fail(`${label} failed: ${(err as Error).message}`, { command: label }, 2);
|
|
142
|
+
}
|
|
137
143
|
}
|
|
138
|
-
|
|
144
|
+
);
|
|
139
145
|
|
|
140
146
|
program
|
|
141
147
|
.command('tag [app-name]')
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Tree } from '@nx/devkit';
|
|
2
|
+
|
|
3
|
+
// `dev` is gone from the CLI (superseded by `deploy --preview`), so any
|
|
4
|
+
// consumer's `start` script still pointing at it is now a dangling command.
|
|
5
|
+
// Only removes the exact value the template ever wrote — leaves a
|
|
6
|
+
// consumer-customized `start` script alone.
|
|
7
|
+
export default function removeStartScript(tree: Tree): void {
|
|
8
|
+
for (const entry of tree.children('.')) {
|
|
9
|
+
if (entry.startsWith('.')) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const pkgPath = `${entry}/package.json`;
|
|
13
|
+
if (!tree.exists(pkgPath)) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pkg = JSON.parse(tree.read(pkgPath, 'utf-8')!);
|
|
18
|
+
|
|
19
|
+
if (pkg.scripts?.start === 'z2h-cli dev') {
|
|
20
|
+
delete pkg.scripts.start;
|
|
21
|
+
tree.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -35,7 +35,6 @@ describe('buildShadowPkg', () => {
|
|
|
35
35
|
it('uses plain trident scripts (no `-T`); bin resolves via the local devDep', () => {
|
|
36
36
|
const shadow = buildShadowPkg({ name: 'demo' });
|
|
37
37
|
expect(shadow.scripts).toEqual({
|
|
38
|
-
start: 'trident start',
|
|
39
38
|
build: 'trident build',
|
|
40
39
|
});
|
|
41
40
|
});
|
|
@@ -37,9 +37,9 @@ When a request needs data, follow `z2h:coding-standards-mf` §0: write a `runQue
|
|
|
37
37
|
|
|
38
38
|
- This folder is **standalone** — not part of any monorepo, no git initialized.
|
|
39
39
|
- `src/index.tsx` default-exports the React component the host MF loads.
|
|
40
|
-
- `package.json` wires the `z2h-cli` for `build` / `
|
|
40
|
+
- `package.json` wires the `z2h-cli` for `build` / `deploy`.
|
|
41
41
|
- `.zth/` is an auto-generated shadow workspace — never edit it by hand; `z2h-cli clean` wipes it.
|
|
42
|
-
-
|
|
42
|
+
- Preview: `z2h-cli deploy --preview` → shareable, ephemeral link, no git, no live deploy.
|
|
43
43
|
- Production: `z2h-cli deploy` → live at `https://bigbrain.me/bigbrain-vibe/<app>`.
|
|
44
44
|
- There is no git, no PR, no CI. The gate is: lint pass → typecheck pass → user previews and approves.
|
|
45
45
|
|
|
@@ -78,6 +78,6 @@ Every step transition includes a time estimate. Transition pattern: what just fi
|
|
|
78
78
|
|
|
79
79
|
## Where the Skills Live
|
|
80
80
|
|
|
81
|
-
The Z2H plugin is installed globally — every Claude session has access to `z2h:zero-to-hero`, `z2h:feature-development`, `z2h:
|
|
81
|
+
The Z2H plugin is installed globally — every Claude session has access to `z2h:zero-to-hero`, `z2h:feature-development`, `z2h:preview`, `z2h:plan`, etc. **Always go through `z2h:zero-to-hero`** for user requests; never invoke sub-skills directly.
|
|
82
82
|
|
|
83
83
|
If `z2h:zero-to-hero` is not available in this session, the plugin isn't installed in the user's Claude config. Direct them to: `claude plugin marketplace add <bigbrain-z2h-path>` then `claude plugin install z2h`.
|
|
@@ -25,8 +25,8 @@ for the second flow.
|
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
cd <app-name>
|
|
28
|
-
z2h-cli
|
|
29
|
-
z2h-cli deploy
|
|
28
|
+
z2h-cli deploy --preview # ephemeral, shareable link — no git, no live deploy
|
|
29
|
+
z2h-cli deploy # publishes to S3, live at https://bigbrain.me/bigbrain-vibe/<app>
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Reset
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildPreviewSlug, mfAppName, validateAppName } from '../app-name';
|
|
3
|
+
import { APP_NAME_PATTERN } from '../../constants';
|
|
4
|
+
|
|
5
|
+
const PREVIEW_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
6
|
+
|
|
7
|
+
describe('buildPreviewSlug', () => {
|
|
8
|
+
it('produces zth-preview-<8hex>-<realAppName>', () => {
|
|
9
|
+
const slug = buildPreviewSlug('my-app', PREVIEW_ID);
|
|
10
|
+
expect(slug).toBe('zth-preview-a1b2c3d4-my-app');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('strips dashes from the previewId before taking the first 8 chars', () => {
|
|
14
|
+
const slug = buildPreviewSlug('my-app', PREVIEW_ID);
|
|
15
|
+
expect(slug.split('-').slice(0, 3).join('-')).toBe('zth-preview-a1b2c3d4');
|
|
16
|
+
expect(slug).not.toContain('a1b2-c3d4');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('always yields an mf-prefixed appName that satisfies APP_NAME_PATTERN', () => {
|
|
20
|
+
const names = [
|
|
21
|
+
buildPreviewSlug('a', PREVIEW_ID),
|
|
22
|
+
buildPreviewSlug('my-app', PREVIEW_ID),
|
|
23
|
+
buildPreviewSlug('an-extremely-long-real-application-name-that-would-never-fit-on-its-own', PREVIEW_ID),
|
|
24
|
+
];
|
|
25
|
+
for (const slug of names) {
|
|
26
|
+
const appName = mfAppName(slug);
|
|
27
|
+
expect(appName.length).toBeLessThanOrEqual(50);
|
|
28
|
+
expect(APP_NAME_PATTERN.test(appName)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('truncates a long real app name rather than exceeding the 50-char mf-prefixed cap', () => {
|
|
33
|
+
const longName = 'an-extremely-long-real-application-name-that-would-never-fit-on-its-own';
|
|
34
|
+
const slug = buildPreviewSlug(longName, PREVIEW_ID);
|
|
35
|
+
const appName = mfAppName(slug);
|
|
36
|
+
expect(appName.length).toBe(50);
|
|
37
|
+
expect(slug.startsWith('zth-preview-a1b2c3d4-')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('two different previewIds for the same real app name produce different slugs', () => {
|
|
41
|
+
const otherId = 'ffffffff-1111-2222-3333-444444444444';
|
|
42
|
+
expect(buildPreviewSlug('my-app', PREVIEW_ID)).not.toBe(buildPreviewSlug('my-app', otherId));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('strips a scoped package name the same way mfAppName does, so the slug stays valid', () => {
|
|
46
|
+
const slug = buildPreviewSlug('@mondaydotcomorg/my-app', PREVIEW_ID);
|
|
47
|
+
expect(slug).toBe('zth-preview-a1b2c3d4-my-app');
|
|
48
|
+
expect(APP_NAME_PATTERN.test(mfAppName(slug))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('strips a trailing hyphen left dangling by the 47-char slice', () => {
|
|
52
|
+
// Prefix (12) + shortId (8) + '-' (1) = 21 chars before cleanRealAppName.
|
|
53
|
+
// A hyphen at cleanRealAppName[25] lands exactly on the 47-char cut.
|
|
54
|
+
const cleanRealAppName = `${'a'.repeat(25)}-${'z'.repeat(20)}`;
|
|
55
|
+
const slug = buildPreviewSlug(cleanRealAppName, PREVIEW_ID);
|
|
56
|
+
expect(slug.endsWith('-')).toBe(false);
|
|
57
|
+
expect(APP_NAME_PATTERN.test(mfAppName(slug))).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('validateAppName', () => {
|
|
62
|
+
it('accepts a valid kebab-case name', () => {
|
|
63
|
+
expect(() => validateAppName('my-app')).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects names that fail APP_NAME_PATTERN', () => {
|
|
67
|
+
expect(() => validateAppName('My-App')).toThrow(/invalid/);
|
|
68
|
+
expect(() => validateAppName('-leading-dash')).toThrow(/invalid/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects names reserved by the Z2H host', () => {
|
|
72
|
+
expect(() => validateAppName('generate-z2h-token')).toThrow(/reserved by the Z2H host/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects names starting with the preview prefix', () => {
|
|
76
|
+
expect(() => validateAppName('zth-preview-abc123-foo')).toThrow(/reserved for the ephemeral preview mechanism/);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/util/app-name.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { APP_NAME_PATTERN, PREVIEW_APP_NAME_PREFIX } from '../constants';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Strip any npm scope (`@scope/`) from a package name.
|
|
@@ -24,3 +25,57 @@ export function getCwdAppName(): string | undefined {
|
|
|
24
25
|
export function mfAppName(name: string): string {
|
|
25
26
|
return `mf-${cleanAppName(name)}`;
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
// Names reserved by the host MF (mf-bigbrain-zth) for platform pages under
|
|
30
|
+
// /bigbrain-vibe/<name>. Creating an app with one of these would shadow the
|
|
31
|
+
// platform page in the URL, so we refuse upfront.
|
|
32
|
+
const RESERVED_APP_NAMES = new Set(['generate-z2h-token']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validates a de-scoped, kebab-case app name against every rule shared by
|
|
36
|
+
* `create` and `deploy` (incl. `deploy --preview`) — pattern, reserved
|
|
37
|
+
* platform names, and the preview-prefix reservation. Throws with a
|
|
38
|
+
* friendly message on the first violation.
|
|
39
|
+
*/
|
|
40
|
+
export function validateAppName(appName: string): void {
|
|
41
|
+
if (RESERVED_APP_NAMES.has(appName)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`App name "${appName}" is reserved by the Z2H host for a platform page under /bigbrain-vibe/${appName}. Pick a different name.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (!APP_NAME_PATTERN.test(appName)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`App name "${appName}" is invalid. Use lowercase kebab-case (a-z, 0-9, -), ` +
|
|
49
|
+
`2-50 chars, starting with a letter.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (appName.startsWith(PREVIEW_APP_NAME_PREFIX)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`App names starting with "${PREVIEW_APP_NAME_PREFIX}" are reserved for the ephemeral preview mechanism. Pick a different name.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Preview slugs are capped here so the mf-prefixed name (`mf-` + slug) stays
|
|
60
|
+
// within APP_NAME_PATTERN's 50-char limit (3 chars reserved for `mf-`).
|
|
61
|
+
const PREVIEW_SLUG_MAX_LEN = 47;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds the slug for a preview's synthetic RDS app row:
|
|
65
|
+
* `zth-preview-<shortid>-<realAppName>`, pre-`mf-`-prefixing. Uniqueness
|
|
66
|
+
* comes entirely from `shortId` (derived from the previewId, already a
|
|
67
|
+
* randomUUID() — 32 bits of entropy); truncating the human-readable tail to
|
|
68
|
+
* fit the cap is safe on its own, a shortId collision is astronomically
|
|
69
|
+
* unlikely at POC scale and would just 409 on registerApp for a retry.
|
|
70
|
+
*/
|
|
71
|
+
export function buildPreviewSlug(appName: string, previewId: string): string {
|
|
72
|
+
const shortId = previewId.replace(/-/g, '').slice(0, 8);
|
|
73
|
+
const base = `${PREVIEW_APP_NAME_PREFIX}${shortId}-${cleanAppName(appName)}`;
|
|
74
|
+
// Trailing hyphens are stripped post-slice — a cut that lands right after a
|
|
75
|
+
// hyphen in appName would otherwise leave one dangling.
|
|
76
|
+
return base.slice(0, PREVIEW_SLUG_MAX_LEN).replace(/-+$/, '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isPreviewAppName(appName: string): boolean {
|
|
80
|
+
return appName.startsWith(`mf-${PREVIEW_APP_NAME_PREFIX}`);
|
|
81
|
+
}
|
|
@@ -65,7 +65,7 @@ describe('fetchDeployVersion', () => {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
describe('brokerCredentialProvider', () => {
|
|
68
|
-
it('POSTs the appName
|
|
68
|
+
it('POSTs the appName to the broker with a Bearer token + parses the response', async () => {
|
|
69
69
|
const fetchMock = mockFetchOnce({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY });
|
|
70
70
|
const provider = brokerCredentialProvider({ appName: 'my-app' });
|
|
71
71
|
const creds = await provider();
|
|
@@ -145,4 +145,56 @@ describe('brokerCredentialProvider', () => {
|
|
|
145
145
|
const provider = brokerCredentialProvider({ appName: 'my-app' });
|
|
146
146
|
await expect(provider()).rejects.toThrow(/missing required credential fields/);
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
describe('with previewId', () => {
|
|
150
|
+
it('POSTs appName + previewId to the same STS path with a Bearer token + parses the response', async () => {
|
|
151
|
+
const fetchMock = mockFetchOnce({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY });
|
|
152
|
+
const provider = brokerCredentialProvider({ appName: 'mf-my-app', previewId: 'preview-1' });
|
|
153
|
+
const creds = await provider();
|
|
154
|
+
|
|
155
|
+
expect(creds.accessKeyId).toBe('AKIA-stub');
|
|
156
|
+
expect(creds.secretAccessKey).toBe('secret-stub');
|
|
157
|
+
expect(creds.sessionToken).toBe('session-stub');
|
|
158
|
+
expect(creds.expiration?.toISOString()).toBe(FAR_FUTURE_EXPIRY);
|
|
159
|
+
|
|
160
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
161
|
+
`${DEFAULT_BROKER_BASE_URL}${STS_BROKER_PATH}`,
|
|
162
|
+
expect.objectContaining({
|
|
163
|
+
method: 'POST',
|
|
164
|
+
body: JSON.stringify({ appName: 'mf-my-app', previewId: 'preview-1' }),
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
const sent = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
|
|
168
|
+
expect(sent.Authorization).toBe('Bearer jwt-stub');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('caches and coalesces calls when expiry is far away', async () => {
|
|
172
|
+
const fetchMock = mockFetchOnce({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY });
|
|
173
|
+
const provider = brokerCredentialProvider({ appName: 'mf-my-app', previewId: 'preview-1' });
|
|
174
|
+
|
|
175
|
+
const first = await provider();
|
|
176
|
+
await provider();
|
|
177
|
+
await provider();
|
|
178
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
179
|
+
|
|
180
|
+
const [a, b, c] = await Promise.all([provider(), provider(), provider()]);
|
|
181
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
182
|
+
expect(a).toBe(first);
|
|
183
|
+
expect(b).toBe(first);
|
|
184
|
+
expect(c).toBe(first);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('surfaces a remediation hint on 401', async () => {
|
|
188
|
+
mockFetchOnce({}, { status: 401, text: 'Unauthorized' });
|
|
189
|
+
const provider = brokerCredentialProvider({ appName: 'mf-my-app', previewId: 'preview-1' });
|
|
190
|
+
await expect(provider()).rejects.toThrow(/token rejected by the broker/);
|
|
191
|
+
await expect(provider()).rejects.toThrow(/generate-z2h-token/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('rejects responses missing required credential fields', async () => {
|
|
195
|
+
mockFetchOnce({ accessKeyId: 'AKIA-stub' });
|
|
196
|
+
const provider = brokerCredentialProvider({ appName: 'mf-my-app', previewId: 'preview-1' });
|
|
197
|
+
await expect(provider()).rejects.toThrow(/missing required credential fields/);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
148
200
|
});
|
|
@@ -64,34 +64,40 @@ export async function fetchDeployVersion(appName: string): Promise<number> {
|
|
|
64
64
|
return body.version ?? 0;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
async function
|
|
68
|
-
const res = await postToBroker(
|
|
67
|
+
async function requestSts(path: string, body: unknown, errorLabel: string): Promise<AwsCredentialIdentity> {
|
|
68
|
+
const res = await postToBroker(path, body);
|
|
69
69
|
assertBrokerOk(res);
|
|
70
70
|
if (!res.ok) {
|
|
71
71
|
const text = await res.text();
|
|
72
|
-
throw new Error(`Z2H
|
|
72
|
+
throw new Error(`Z2H ${errorLabel} broker returned ${res.status}: ${text.slice(0, 1024)}`);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
75
|
+
const responseBody = (await res.json()) as CredentialsResponse;
|
|
76
|
+
if (
|
|
77
|
+
!responseBody.accessKeyId ||
|
|
78
|
+
!responseBody.secretAccessKey ||
|
|
79
|
+
!responseBody.sessionToken ||
|
|
80
|
+
!responseBody.expiration
|
|
81
|
+
) {
|
|
82
|
+
throw new Error(`Z2H ${errorLabel} broker response missing required credential fields`);
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
return {
|
|
81
|
-
accessKeyId:
|
|
82
|
-
secretAccessKey:
|
|
83
|
-
sessionToken:
|
|
84
|
-
expiration: new Date(
|
|
86
|
+
accessKeyId: responseBody.accessKeyId,
|
|
87
|
+
secretAccessKey: responseBody.secretAccessKey,
|
|
88
|
+
sessionToken: responseBody.sessionToken,
|
|
89
|
+
expiration: new Date(responseBody.expiration),
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* single in-flight fetch.
|
|
94
|
+
* Wraps a raw STS fetch in the shared cache/coalescing state machine:
|
|
95
|
+
* credentials are cached in memory until 5 minutes before expiry, and
|
|
96
|
+
* concurrent calls share a single in-flight fetch.
|
|
93
97
|
*/
|
|
94
|
-
|
|
98
|
+
function createCachedCredentialProvider(
|
|
99
|
+
fetchOnce: () => Promise<AwsCredentialIdentity>
|
|
100
|
+
): AwsCredentialIdentityProvider {
|
|
95
101
|
let cached: AwsCredentialIdentity | undefined;
|
|
96
102
|
let inflight: Promise<AwsCredentialIdentity> | undefined;
|
|
97
103
|
|
|
@@ -104,7 +110,7 @@ export function brokerCredentialProvider({ appName }: { appName: string }): AwsC
|
|
|
104
110
|
return inflight;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
inflight =
|
|
113
|
+
inflight = fetchOnce()
|
|
108
114
|
.then(credentials => {
|
|
109
115
|
cached = credentials;
|
|
110
116
|
return credentials;
|
|
@@ -115,3 +121,22 @@ export function brokerCredentialProvider({ appName }: { appName: string }): AwsC
|
|
|
115
121
|
return inflight;
|
|
116
122
|
};
|
|
117
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* AWS SDK credential provider that fetches short-lived credentials from the
|
|
127
|
+
* bigbrain-zth STS broker, scoped to `appName` — or, when `previewId` is
|
|
128
|
+
* given, to a single `previews/<appName>/<previewId>/*` slot instead of the
|
|
129
|
+
* full deploy path. Credentials are cached in memory until 5 minutes before
|
|
130
|
+
* expiry; concurrent calls share a single in-flight fetch.
|
|
131
|
+
*/
|
|
132
|
+
export function brokerCredentialProvider({
|
|
133
|
+
appName,
|
|
134
|
+
previewId,
|
|
135
|
+
}: {
|
|
136
|
+
appName: string;
|
|
137
|
+
previewId?: string;
|
|
138
|
+
}): AwsCredentialIdentityProvider {
|
|
139
|
+
return createCachedCredentialProvider(() =>
|
|
140
|
+
requestSts(STS_BROKER_PATH, { appName, previewId }, previewId ? 'preview' : 'deploy')
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { registerOrUpdateApp } from '../app';
|
|
3
|
+
import * as promptVisibility from '../../prompt-visibility';
|
|
4
|
+
import type { ManifestEntry } from '../../manifest';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../prompt-visibility', () => ({
|
|
7
|
+
resolveAppVisibility: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
function mockFetchOnce(status = 200) {
|
|
11
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: status < 400, status, text: async () => '' });
|
|
12
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
13
|
+
return fetchMock;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ENTRY: ManifestEntry = {
|
|
17
|
+
sameAsHost: false,
|
|
18
|
+
skipTranslationFetch: false,
|
|
19
|
+
shared: [],
|
|
20
|
+
entrypoints: ['main.js'],
|
|
21
|
+
version: 1,
|
|
22
|
+
url: 'https://cdn/x',
|
|
23
|
+
deployedBy: 'u@x.com',
|
|
24
|
+
gitRemote: '',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('registerOrUpdateApp', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.mocked(promptVisibility.resolveAppVisibility).mockReset().mockResolvedValue('private');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('registers (not updates) when nextVersion is 1, prompting for visibility', async () => {
|
|
37
|
+
const fetchMock = mockFetchOnce();
|
|
38
|
+
|
|
39
|
+
await registerOrUpdateApp({
|
|
40
|
+
appName: 'mf-my-app',
|
|
41
|
+
token: 'jwt',
|
|
42
|
+
nextVersion: 1,
|
|
43
|
+
entry: ENTRY,
|
|
44
|
+
gitRemote: '',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(promptVisibility.resolveAppVisibility).toHaveBeenCalledWith('mf-my-app');
|
|
48
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
49
|
+
const [calledUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
50
|
+
expect(calledUrl).toContain('/z2h-cli/apps/register');
|
|
51
|
+
expect(JSON.parse(init.body as string)).toMatchObject({
|
|
52
|
+
appName: 'mf-my-app',
|
|
53
|
+
version: 1,
|
|
54
|
+
visibility: 'private',
|
|
55
|
+
gitRemote: '',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('updates (not registers) when nextVersion > 1, without prompting for visibility', async () => {
|
|
60
|
+
const fetchMock = mockFetchOnce();
|
|
61
|
+
|
|
62
|
+
await registerOrUpdateApp({
|
|
63
|
+
appName: 'mf-my-app',
|
|
64
|
+
token: 'jwt',
|
|
65
|
+
nextVersion: 3,
|
|
66
|
+
entry: ENTRY,
|
|
67
|
+
gitRemote: '',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(promptVisibility.resolveAppVisibility).not.toHaveBeenCalled();
|
|
71
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
72
|
+
const [calledUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
73
|
+
expect(calledUrl).toContain('/z2h-cli/apps/update');
|
|
74
|
+
expect(JSON.parse(init.body as string)).toMatchObject({ appName: 'mf-my-app', version: 3 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('propagates a registration failure', async () => {
|
|
78
|
+
mockFetchOnce(409);
|
|
79
|
+
|
|
80
|
+
await expect(
|
|
81
|
+
registerOrUpdateApp({
|
|
82
|
+
appName: 'mf-my-app',
|
|
83
|
+
token: 'jwt',
|
|
84
|
+
nextVersion: 1,
|
|
85
|
+
entry: ENTRY,
|
|
86
|
+
gitRemote: '',
|
|
87
|
+
})
|
|
88
|
+
).rejects.toThrow(/App registration failed/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('propagates an update failure', async () => {
|
|
92
|
+
mockFetchOnce(500);
|
|
93
|
+
|
|
94
|
+
await expect(
|
|
95
|
+
registerOrUpdateApp({
|
|
96
|
+
appName: 'mf-my-app',
|
|
97
|
+
token: 'jwt',
|
|
98
|
+
nextVersion: 2,
|
|
99
|
+
entry: ENTRY,
|
|
100
|
+
gitRemote: '',
|
|
101
|
+
})
|
|
102
|
+
).rejects.toThrow(/App update failed/);
|
|
103
|
+
});
|
|
104
|
+
});
|