@tilewise/workbench-client 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +37 -2
- package/dist/vite/checkout.js +2 -22
- package/dist/vite/environment.d.ts +4 -0
- package/dist/vite/environment.js +5 -0
- package/dist/vite/fetchBridge.d.ts +3 -1
- package/dist/vite/fetchBridge.js +13 -4
- package/dist/vite/git.d.ts +2 -0
- package/dist/vite/git.js +5 -0
- package/dist/vite/https.d.ts +17 -0
- package/dist/vite/https.js +34 -0
- package/dist/vite/install.d.ts +5 -0
- package/dist/vite/install.js +30 -0
- package/dist/vite/pull.d.ts +16 -0
- package/dist/vite/pull.js +84 -0
- package/dist/vite/workbenchLocal.d.ts +16 -1
- package/dist/vite/workbenchLocal.js +50 -9
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 · 2026-11-12 05:46 +04:00
|
|
4
|
+
|
|
5
|
+
- feat(workbench-client): pull a branch forward from the switcher (84ca115)
|
|
6
|
+
- Add /__workbench/pull: git fetch, then fast-forward a branch to its branch on GitHub, never merging or rebasing
|
|
7
|
+
- Move the checked-out branch's files too, refused while anything is uncommitted, and install when the lockfile changed
|
|
8
|
+
- Move any other branch's ref alone, refused when another worktree has it checked out
|
|
9
|
+
- Refuse a branch with commits GitHub doesn't have, or with no branch there, saying why
|
|
10
|
+
- Share Checkout's install step in install.ts
|
|
11
|
+
- Show Pull beside History on each branch behind GitHub's, with the result or the reason in the switcher's footer
|
|
12
|
+
- Ask for a newer client when the dev server has no Pull
|
|
13
|
+
|
|
14
|
+
## 0.2.0 · 2026-11-10 17:08 +04:00
|
|
15
|
+
|
|
16
|
+
- feat(workbench-client)!: serve the dev server over https by default, for Safari (4219956)
|
|
17
|
+
- Serve https with a certificate for localhost from Vite's own @vitejs/plugin-basic-ssl, one for every project on the computer, kept in
|
|
18
|
+
- Add the https option and WORKBENCH_HTTPS (1 or 0) to choose, keeping an app's own server.https, and http under Vitest
|
|
19
|
+
- Log how to trust the certificate on a Mac and on Windows at each start
|
|
20
|
+
- Link the viewer to the dev server's https address
|
|
21
|
+
- Describe https in the README
|
|
22
|
+
- BREAKING CHANGE: the dev server answers on https://localhost:<port> instead of http://localhost:<port>; set https: false or WORKBENCH_HTTPS=0 to keep http
|
|
23
|
+
- fix(workbench-client): leave out HTTP/2 pseudo-headers when asking a fake API (a326e03)
|
|
24
|
+
- Skip :method, :path and the other pseudo-headers a request over HTTP/2 carries, which fetch refuses, so a dev server on https answers /api
|
|
25
|
+
- Leave out keep-alive, upgrade and proxy-connection from a fake API's answer, which HTTP/2 refuses
|
|
26
|
+
- Test a request as HTTP/2 hands it over
|
|
27
|
+
- feat(workbench-client): hand the dev server's token to the viewer's origin at /connect (0ab508c)
|
|
28
|
+
- Add GET /__workbench/connect, which answers { token, repo } to a request from an allowed origin (WORKBENCH_URL or a viewer on localhost) and nothing
|
|
29
|
+
- Read the repository's owner/name in one helper, for the link and /connect
|
|
30
|
+
- Test that only the viewer's origin gets the token
|
|
31
|
+
- Describe connecting in the README
|
|
32
|
+
|
|
3
33
|
## 0.1.2 · 2026-11-07 06:11 +04:00
|
|
4
34
|
|
|
5
35
|
- feat(workbench-client): read the fake API from workbench.json (25e1ad9)
|
package/README.md
CHANGED
|
@@ -174,6 +174,14 @@ git-ignored `.builds/<sha>/`, and checks a branch out, refused while
|
|
|
174
174
|
anything is uncommitted. Builds are served under `/__workbench/b/<sha>/`, so
|
|
175
175
|
the app's router takes `basepath: import.meta.env.BASE_URL`.
|
|
176
176
|
|
|
177
|
+
It also pulls a branch, fast-forward only (`/__workbench/pull`): `git fetch`,
|
|
178
|
+
then the branch moves to its branch on GitHub, never merging or rebasing. The
|
|
179
|
+
checked-out branch moves its files too, refused while anything is
|
|
180
|
+
uncommitted, and installs at the repository's top when the lockfile changed,
|
|
181
|
+
then restarts the dev server. Any other branch moves alone, refused when
|
|
182
|
+
another worktree has it checked out. A branch with commits GitHub doesn't
|
|
183
|
+
have, or with no branch there, is refused, with the reason.
|
|
184
|
+
|
|
177
185
|
It also reads `workbench.json`, at the repository's top, where the
|
|
178
186
|
repository declares its setup: the fake API comes from it (below), and
|
|
179
187
|
`/__workbench/setup` hands the checkout's to the viewer, as `{ name, viewers }`
|
|
@@ -181,8 +189,35 @@ repository declares its setup: the fake API comes from it (below), and
|
|
|
181
189
|
read it from GitHub.
|
|
182
190
|
|
|
183
191
|
Only the viewer may ask: from its origin (`WORKBENCH_URL`, or a viewer on
|
|
184
|
-
localhost) with the
|
|
185
|
-
|
|
192
|
+
localhost) with the dev server's token. The viewer has it from the link the
|
|
193
|
+
dev server logs, which `pnpm dev` opens, or by connecting to the dev server's
|
|
194
|
+
port: `/__workbench/connect` hands the token, and the repository it serves,
|
|
195
|
+
to a page of those origins alone, as the browser names a page's origin and
|
|
196
|
+
no site can claim another's. Without `WORKBENCH_URL` the dev server opens no
|
|
197
|
+
viewer, and only a viewer on localhost can connect.
|
|
198
|
+
|
|
199
|
+
### https
|
|
200
|
+
|
|
201
|
+
The dev server serves https, so Safari can use Local too. Safari blocks a
|
|
202
|
+
secure page, as the viewer is, from reaching `http://localhost`, which
|
|
203
|
+
Chrome and Firefox allow. The certificate comes from Vite's own
|
|
204
|
+
[`@vitejs/plugin-basic-ssl`](https://github.com/vitejs/vite-plugin-basic-ssl).
|
|
205
|
+
It is made for localhost, one for every project on the computer, and kept in
|
|
206
|
+
`~/.workbench/https` for 825 days, Apple's limit.
|
|
207
|
+
|
|
208
|
+
Each computer trusts it once, as the dev server's log says at each start:
|
|
209
|
+
|
|
210
|
+
- **Mac** (Safari, and Chrome with it): open `https://localhost:5173` in
|
|
211
|
+
Safari, choose *Show Details* › *visit this website*, and enter your
|
|
212
|
+
password.
|
|
213
|
+
- **Windows** (Chrome and Edge):
|
|
214
|
+
`certutil -user -addstore TrustedPeople "%USERPROFILE%\.workbench\https\localhost.crt"`.
|
|
215
|
+
TrustedPeople trusts this certificate alone, never one it signs.
|
|
216
|
+
|
|
217
|
+
`https: false` in the options, or `WORKBENCH_HTTPS=0`, serves http.
|
|
218
|
+
`WORKBENCH_HTTPS=1` serves https whatever the options say. An app that sets
|
|
219
|
+
its own `server.https` keeps it. Under Vitest it is http, unless `https` is
|
|
220
|
+
set.
|
|
186
221
|
|
|
187
222
|
### An app in a workspace
|
|
188
223
|
|
package/dist/vite/checkout.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { lockOf } from './builds.js';
|
|
2
|
+
import { installIfChanged } from './install.js';
|
|
3
3
|
/**
|
|
4
4
|
* Checks a branch out, refused while anything is uncommitted. When the
|
|
5
5
|
* lockfile changes, installs and restarts the dev server.
|
|
@@ -18,26 +18,6 @@ export async function checkout(git, root, branch, restart) {
|
|
|
18
18
|
}
|
|
19
19
|
const before = (await lockOf(root)).text;
|
|
20
20
|
await git('switch', branch);
|
|
21
|
-
const
|
|
22
|
-
const installed = before !== after.text;
|
|
23
|
-
if (installed) {
|
|
24
|
-
await new Promise((resolve, reject) => {
|
|
25
|
-
const args = after.manager === 'pnpm' ? ['install'] : ['install', '--no-audit', '--no-fund'];
|
|
26
|
-
const child = spawn(after.manager, args, {
|
|
27
|
-
cwd: root,
|
|
28
|
-
shell: process.platform === 'win32',
|
|
29
|
-
stdio: 'inherit',
|
|
30
|
-
});
|
|
31
|
-
child.on('close', (code) => {
|
|
32
|
-
if (code === 0) {
|
|
33
|
-
resolve();
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
reject(new Error(`${after.manager} install exited with ${code}`));
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
setTimeout(restart, 50);
|
|
41
|
-
}
|
|
21
|
+
const installed = await installIfChanged(root, before, restart);
|
|
42
22
|
return { status: 200, body: { current: branch, installed } };
|
|
43
23
|
}
|
|
@@ -4,6 +4,10 @@ export declare const environment: {
|
|
|
4
4
|
viewerUrl: string | null;
|
|
5
5
|
/** The package script that started the dev server: only `dev` opens the viewer. */
|
|
6
6
|
script: string | null;
|
|
7
|
+
/** WORKBENCH_HTTPS: `1` serves https, `0` http; unset leaves it to the option. */
|
|
8
|
+
https: boolean | null;
|
|
9
|
+
/** Run by Vitest, whose servers are never opened in a browser. */
|
|
10
|
+
vitest: boolean;
|
|
7
11
|
};
|
|
8
12
|
/** What a build's install and bundle run with: this environment, as CI. */
|
|
9
13
|
export declare const buildEnvironment: () => NodeJS.ProcessEnv;
|
package/dist/vite/environment.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
const viewer = process.env['WORKBENCH_URL']?.replace(/\/$/, '');
|
|
2
|
+
const https = process.env['WORKBENCH_HTTPS'];
|
|
2
3
|
/** Every setting the local builds service reads from the environment. */
|
|
3
4
|
export const environment = {
|
|
4
5
|
/** The viewer's address. Without it, the dev server opens no viewer. */
|
|
5
6
|
viewerUrl: viewer === undefined || viewer === '' ? null : viewer,
|
|
6
7
|
/** The package script that started the dev server: only `dev` opens the viewer. */
|
|
7
8
|
script: process.env['npm_lifecycle_event'] ?? null,
|
|
9
|
+
/** WORKBENCH_HTTPS: `1` serves https, `0` http; unset leaves it to the option. */
|
|
10
|
+
https: https === '1' ? true : https === '0' ? false : null,
|
|
11
|
+
/** Run by Vitest, whose servers are never opened in a browser. */
|
|
12
|
+
vitest: !!process.env['VITEST'],
|
|
8
13
|
};
|
|
9
14
|
/** What a build's install and bundle run with: this environment, as CI. */
|
|
10
15
|
export const buildEnvironment = () => ({ ...process.env, CI: '1' });
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
/**
|
|
3
3
|
* A Node request as a fetch `Request` for a fake API, at `path`: the part of
|
|
4
|
-
* the address after the API's mount, with its query.
|
|
4
|
+
* the address after the API's mount, with its query. HTTP/2's pseudo-headers
|
|
5
|
+
* (`:method`, `:path`…), which the https dev server's requests carry, are
|
|
6
|
+
* no headers to fetch, so they are left out.
|
|
5
7
|
*/
|
|
6
8
|
export declare function toRequest(request: IncomingMessage, path: string): Promise<Request>;
|
|
7
9
|
/** Writes a fake API's fetch `Response` to a Node response. */
|
package/dist/vite/fetchBridge.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
/** Headers Node works out for itself, from what it sends. */
|
|
2
|
-
const COMPUTED = new Set([
|
|
1
|
+
/** Headers Node works out for itself, from what it sends, and that HTTP/2 refuses. */
|
|
2
|
+
const COMPUTED = new Set([
|
|
3
|
+
'content-length',
|
|
4
|
+
'transfer-encoding',
|
|
5
|
+
'connection',
|
|
6
|
+
'keep-alive',
|
|
7
|
+
'upgrade',
|
|
8
|
+
'proxy-connection',
|
|
9
|
+
]);
|
|
3
10
|
const bodyOf = (request) => {
|
|
4
11
|
return new Promise((resolve, reject) => {
|
|
5
12
|
const chunks = [];
|
|
@@ -10,13 +17,15 @@ const bodyOf = (request) => {
|
|
|
10
17
|
};
|
|
11
18
|
/**
|
|
12
19
|
* A Node request as a fetch `Request` for a fake API, at `path`: the part of
|
|
13
|
-
* the address after the API's mount, with its query.
|
|
20
|
+
* the address after the API's mount, with its query. HTTP/2's pseudo-headers
|
|
21
|
+
* (`:method`, `:path`…), which the https dev server's requests carry, are
|
|
22
|
+
* no headers to fetch, so they are left out.
|
|
14
23
|
*/
|
|
15
24
|
export async function toRequest(request, path) {
|
|
16
25
|
const method = request.method ?? 'GET';
|
|
17
26
|
const headers = new Headers();
|
|
18
27
|
for (const [name, value] of Object.entries(request.headers)) {
|
|
19
|
-
if (value === undefined) {
|
|
28
|
+
if (value === undefined || name.startsWith(':')) {
|
|
20
29
|
continue;
|
|
21
30
|
}
|
|
22
31
|
for (const each of Array.isArray(value) ? value : [value]) {
|
package/dist/vite/git.d.ts
CHANGED
|
@@ -5,3 +5,5 @@ export declare function gitIn(root: string): Git;
|
|
|
5
5
|
export declare const refExists: (git: Git, ref: string) => Promise<boolean>;
|
|
6
6
|
/** The repository's default branch, as its remote has it, or `main`. */
|
|
7
7
|
export declare function defaultBranch(git: Git): Promise<string>;
|
|
8
|
+
/** The repository on GitHub, as `owner/name`, from its `origin` remote; null for any other. */
|
|
9
|
+
export declare function repositoryOf(git: Git): Promise<string | null>;
|
package/dist/vite/git.js
CHANGED
|
@@ -16,3 +16,8 @@ export async function defaultBranch(git) {
|
|
|
16
16
|
const head = await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD').catch(() => '');
|
|
17
17
|
return head.trim().replace(/^origin\//, '') || 'main';
|
|
18
18
|
}
|
|
19
|
+
/** The repository on GitHub, as `owner/name`, from its `origin` remote; null for any other. */
|
|
20
|
+
export async function repositoryOf(git) {
|
|
21
|
+
const remote = await git('remote', 'get-url', 'origin').catch(() => '');
|
|
22
|
+
return /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\s*$/.exec(remote)?.[1] ?? null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface Certificate {
|
|
2
|
+
/** The key and the certificate, as Vite's `server.https` takes them. */
|
|
3
|
+
pem: string;
|
|
4
|
+
/** The certificate alone, the file a browser is told to trust. */
|
|
5
|
+
file: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The dev server's certificate for localhost, made by Vite's own basic-ssl.
|
|
9
|
+
* One for every project on this computer, kept in `~/.workbench/https` for
|
|
10
|
+
* 825 days, so a browser trusts it once. `folder` is for tests.
|
|
11
|
+
*/
|
|
12
|
+
export declare function localhostCertificate(folder?: string): Promise<Certificate>;
|
|
13
|
+
/**
|
|
14
|
+
* How to trust the certificate, once per computer, for the dev server's log
|
|
15
|
+
* at each start: whether this computer trusts it can't be told from here.
|
|
16
|
+
*/
|
|
17
|
+
export declare function trustSteps(file: string, port: number): string[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { getCertificate } from '@vitejs/plugin-basic-ssl';
|
|
5
|
+
/** Apple's longest lifetime for a certificate its browsers trust. */
|
|
6
|
+
const DAYS = 825;
|
|
7
|
+
const CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/;
|
|
8
|
+
/**
|
|
9
|
+
* The dev server's certificate for localhost, made by Vite's own basic-ssl.
|
|
10
|
+
* One for every project on this computer, kept in `~/.workbench/https` for
|
|
11
|
+
* 825 days, so a browser trusts it once. `folder` is for tests.
|
|
12
|
+
*/
|
|
13
|
+
export async function localhostCertificate(folder = join(homedir(), '.workbench', 'https')) {
|
|
14
|
+
const pem = await getCertificate(folder, 'localhost', [], DAYS);
|
|
15
|
+
const file = join(folder, 'localhost.crt');
|
|
16
|
+
const certificate = CERTIFICATE.exec(pem)?.[0] ?? '';
|
|
17
|
+
const kept = await readFile(file, 'utf8').catch(() => '');
|
|
18
|
+
if (kept.trim() !== certificate) {
|
|
19
|
+
await mkdir(folder, { recursive: true });
|
|
20
|
+
await writeFile(file, `${certificate}\n`);
|
|
21
|
+
}
|
|
22
|
+
return { pem, file };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* How to trust the certificate, once per computer, for the dev server's log
|
|
26
|
+
* at each start: whether this computer trusts it can't be told from here.
|
|
27
|
+
*/
|
|
28
|
+
export function trustSteps(file, port) {
|
|
29
|
+
return [
|
|
30
|
+
'HTTPS: trust its certificate once on this computer, if you haven’t yet.',
|
|
31
|
+
` Mac: open https://localhost:${port} in Safari, choose Show Details › visit this website, and enter your password`,
|
|
32
|
+
` Windows: certutil -user -addstore TrustedPeople "${file}"`,
|
|
33
|
+
];
|
|
34
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* After the checkout moved: when its lockfile is no longer `before`, installs
|
|
3
|
+
* with its package manager and restarts the dev server. Whether it installed.
|
|
4
|
+
*/
|
|
5
|
+
export declare function installIfChanged(root: string, before: string | null, restart: () => void): Promise<boolean>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { lockOf } from './builds.js';
|
|
3
|
+
/**
|
|
4
|
+
* After the checkout moved: when its lockfile is no longer `before`, installs
|
|
5
|
+
* with its package manager and restarts the dev server. Whether it installed.
|
|
6
|
+
*/
|
|
7
|
+
export async function installIfChanged(root, before, restart) {
|
|
8
|
+
const after = await lockOf(root);
|
|
9
|
+
if (before === after.text) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
const args = after.manager === 'pnpm' ? ['install'] : ['install', '--no-audit', '--no-fund'];
|
|
14
|
+
const child = spawn(after.manager, args, {
|
|
15
|
+
cwd: root,
|
|
16
|
+
shell: process.platform === 'win32',
|
|
17
|
+
stdio: 'inherit',
|
|
18
|
+
});
|
|
19
|
+
child.on('close', (code) => {
|
|
20
|
+
if (code === 0) {
|
|
21
|
+
resolve();
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
reject(new Error(`${after.manager} install exited with ${code}`));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
setTimeout(restart, 50);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Git } from './git.js';
|
|
2
|
+
import { installIfChanged } from './install.js';
|
|
3
|
+
interface Answer {
|
|
4
|
+
status: number;
|
|
5
|
+
body: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Brings a branch up to its branch on GitHub, fast-forward only: git fetch
|
|
9
|
+
* first, then the branch moves to what it fetched, never merging or
|
|
10
|
+
* rebasing. The checked-out branch moves its files too, refused while
|
|
11
|
+
* anything is uncommitted, and installs at `top` when the lockfile changed.
|
|
12
|
+
* Any other branch moves alone, its files untouched. A branch with commits
|
|
13
|
+
* GitHub doesn't have is refused, and so is one with no branch there.
|
|
14
|
+
*/
|
|
15
|
+
export declare function pull(git: Git, top: string, branch: string, restart: () => void, install?: typeof installIfChanged): Promise<Answer>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { lockOf } from './builds.js';
|
|
2
|
+
import { refExists } from './git.js';
|
|
3
|
+
import { installIfChanged } from './install.js';
|
|
4
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
5
|
+
/**
|
|
6
|
+
* Brings a branch up to its branch on GitHub, fast-forward only: git fetch
|
|
7
|
+
* first, then the branch moves to what it fetched, never merging or
|
|
8
|
+
* rebasing. The checked-out branch moves its files too, refused while
|
|
9
|
+
* anything is uncommitted, and installs at `top` when the lockfile changed.
|
|
10
|
+
* Any other branch moves alone, its files untouched. A branch with commits
|
|
11
|
+
* GitHub doesn't have is refused, and so is one with no branch there.
|
|
12
|
+
*/
|
|
13
|
+
export async function pull(git, top, branch, restart, install = installIfChanged) {
|
|
14
|
+
const ref = `refs/heads/${branch}`;
|
|
15
|
+
if (!(await refExists(git, ref))) {
|
|
16
|
+
return { status: 404, body: { error: `No branch ${branch} on this computer` } };
|
|
17
|
+
}
|
|
18
|
+
const upstream = (await git('for-each-ref', '--format=%(upstream)', ref)).trim();
|
|
19
|
+
if (!upstream.startsWith('refs/remotes/origin/')) {
|
|
20
|
+
return {
|
|
21
|
+
status: 409,
|
|
22
|
+
body: { error: `${branch} has no branch on GitHub to pull from.`, reason: 'no-upstream' },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
await git('fetch', '--prune', 'origin');
|
|
26
|
+
if (!(await refExists(git, upstream))) {
|
|
27
|
+
return {
|
|
28
|
+
status: 409,
|
|
29
|
+
body: { error: `${branch}’s branch on GitHub is gone.`, reason: 'no-upstream' },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const [from = '', to = ''] = (await git('rev-parse', ref, upstream)).trim().split(/\s+/);
|
|
33
|
+
const [ahead = 0, behind = 0] = (await git('rev-list', '--left-right', '--count', `${from}...${to}`))
|
|
34
|
+
.trim()
|
|
35
|
+
.split(/\s+/)
|
|
36
|
+
.map(Number);
|
|
37
|
+
if (!behind) {
|
|
38
|
+
return { status: 200, body: { branch, pulled: 0, installed: false } };
|
|
39
|
+
}
|
|
40
|
+
if (ahead) {
|
|
41
|
+
return {
|
|
42
|
+
status: 409,
|
|
43
|
+
body: {
|
|
44
|
+
error: `${branch} has ${plural(ahead, 'commit')} GitHub doesn’t, and GitHub ` +
|
|
45
|
+
`${plural(behind, 'commit')} it doesn’t. Pull only moves a branch forward: ` +
|
|
46
|
+
'merge or rebase it yourself.',
|
|
47
|
+
reason: 'diverged',
|
|
48
|
+
ahead,
|
|
49
|
+
behind,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const current = (await git('symbolic-ref', '--quiet', '--short', 'HEAD').catch(() => '')).trim();
|
|
54
|
+
if (current === branch) {
|
|
55
|
+
const changed = (await git('status', '--porcelain')).split('\n').filter(Boolean).length;
|
|
56
|
+
if (changed) {
|
|
57
|
+
return {
|
|
58
|
+
status: 409,
|
|
59
|
+
body: {
|
|
60
|
+
error: `You have ${plural(changed, 'changed file')}. Commit or stash ${changed === 1 ? 'it' : 'them'} first.`,
|
|
61
|
+
reason: 'changed',
|
|
62
|
+
changed,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const before = (await lockOf(top)).text;
|
|
67
|
+
await git('merge', '--ff-only', '--quiet', to);
|
|
68
|
+
const installed = await install(top, before, restart);
|
|
69
|
+
return { status: 200, body: { branch, pulled: behind, installed } };
|
|
70
|
+
}
|
|
71
|
+
// Checked out in another worktree, it would move under that worktree's files.
|
|
72
|
+
const worktrees = (await git('worktree', 'list', '--porcelain')).split('\n');
|
|
73
|
+
if (worktrees.includes(`branch ${ref}`)) {
|
|
74
|
+
return {
|
|
75
|
+
status: 409,
|
|
76
|
+
body: {
|
|
77
|
+
error: `${branch} is checked out in another worktree. Pull it there.`,
|
|
78
|
+
reason: 'worktree',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
await git('update-ref', '-m', 'pull: fast-forward', ref, to, from);
|
|
83
|
+
return { status: 200, body: { branch, pulled: behind, installed: false } };
|
|
84
|
+
}
|
|
@@ -2,8 +2,9 @@ import type { Plugin } from 'vite';
|
|
|
2
2
|
/**
|
|
3
3
|
* The local builds service, as a Vite plugin: with it, the app's dev server
|
|
4
4
|
* also answers the deployed viewer. It lists this machine's branches and
|
|
5
|
-
* commits, builds any commit,
|
|
5
|
+
* commits, builds any commit, checks a branch out, and pulls one.
|
|
6
6
|
*
|
|
7
|
+
* GET /__workbench/connect { token, repo }: the token, for an allowed origin, which needs none
|
|
7
8
|
* GET /__workbench/setup the checkout's workbench.json: { name, viewers }; 404 none, 422 no setup
|
|
8
9
|
* GET /__workbench/versions branches, tags, what is checked out, changed files
|
|
9
10
|
* GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
|
|
@@ -12,11 +13,17 @@ import type { Plugin } from 'vite';
|
|
|
12
13
|
* GET /__workbench/b/<sha>/… the build, for the viewer's frame
|
|
13
14
|
* POST /__workbench/checkout { branch }: refused while anything is uncommitted
|
|
14
15
|
* POST /__workbench/fetch git fetch, so push status is current
|
|
16
|
+
* POST /__workbench/pull { branch }: git fetch, then fast-forward it to GitHub's (pull.ts)
|
|
15
17
|
*
|
|
16
18
|
* `base` is the branch the others are compared with: the repository's
|
|
17
19
|
* default branch unless set. Builds are served without the token, as a frame
|
|
18
20
|
* can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
|
|
19
21
|
*
|
|
22
|
+
* The viewer has the token from the link the dev server logs, or asks
|
|
23
|
+
* `/connect` for it from its own page. Only a page of an allowed origin gets
|
|
24
|
+
* it: the browser names the page's origin, and no site can claim another's. A
|
|
25
|
+
* request that names none, as a page's request to its own origin, gets none.
|
|
26
|
+
*
|
|
20
27
|
* A fake API beside the app is declared in `workbench.json` at the
|
|
21
28
|
* repository's top, as Proto's `api`: its module from Proto's `folder`, whose
|
|
22
29
|
* default export is a fetch handler, asked at `/api`. The dev server answers
|
|
@@ -28,6 +35,13 @@ import type { Plugin } from 'vite';
|
|
|
28
35
|
*
|
|
29
36
|
* The `api` option overrides the file: `entry`, the module from the app's
|
|
30
37
|
* folder, and `path`, where the app asks it (`/api` unless set).
|
|
38
|
+
*
|
|
39
|
+
* The dev server serves https, so Safari, which blocks a secure page from
|
|
40
|
+
* reaching `http://localhost`, can use it too. Its certificate is Vite's own
|
|
41
|
+
* basic-ssl's, one for every project on this computer, which a browser
|
|
42
|
+
* trusts once (https.ts). `https: false`, or WORKBENCH_HTTPS=0, serves http;
|
|
43
|
+
* WORKBENCH_HTTPS=1 serves https whatever the option says. Under Vitest it is
|
|
44
|
+
* http unless `https` is set, and an app's own `server.https` is left as is.
|
|
31
45
|
*/
|
|
32
46
|
export declare function workbenchLocal(options?: {
|
|
33
47
|
base?: string;
|
|
@@ -35,4 +49,5 @@ export declare function workbenchLocal(options?: {
|
|
|
35
49
|
entry: string;
|
|
36
50
|
path?: string;
|
|
37
51
|
};
|
|
52
|
+
https?: boolean;
|
|
38
53
|
}): Plugin;
|
|
@@ -7,7 +7,9 @@ import { createBuilds } from './builds.js';
|
|
|
7
7
|
import { checkout } from './checkout.js';
|
|
8
8
|
import { environment } from './environment.js';
|
|
9
9
|
import { sendResponse, toRequest } from './fetchBridge.js';
|
|
10
|
-
import { defaultBranch, gitIn } from './git.js';
|
|
10
|
+
import { defaultBranch, gitIn, repositoryOf } from './git.js';
|
|
11
|
+
import { localhostCertificate, trustSteps } from './https.js';
|
|
12
|
+
import { pull } from './pull.js';
|
|
11
13
|
import { sendJson, serveBuild } from './serveBuild.js';
|
|
12
14
|
import { readSetup, setupApi } from './setup.js';
|
|
13
15
|
import { commits, versions } from './versions.js';
|
|
@@ -57,8 +59,9 @@ function open(url) {
|
|
|
57
59
|
/**
|
|
58
60
|
* The local builds service, as a Vite plugin: with it, the app's dev server
|
|
59
61
|
* also answers the deployed viewer. It lists this machine's branches and
|
|
60
|
-
* commits, builds any commit,
|
|
62
|
+
* commits, builds any commit, checks a branch out, and pulls one.
|
|
61
63
|
*
|
|
64
|
+
* GET /__workbench/connect { token, repo }: the token, for an allowed origin, which needs none
|
|
62
65
|
* GET /__workbench/setup the checkout's workbench.json: { name, viewers }; 404 none, 422 no setup
|
|
63
66
|
* GET /__workbench/versions branches, tags, what is checked out, changed files
|
|
64
67
|
* GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
|
|
@@ -67,11 +70,17 @@ function open(url) {
|
|
|
67
70
|
* GET /__workbench/b/<sha>/… the build, for the viewer's frame
|
|
68
71
|
* POST /__workbench/checkout { branch }: refused while anything is uncommitted
|
|
69
72
|
* POST /__workbench/fetch git fetch, so push status is current
|
|
73
|
+
* POST /__workbench/pull { branch }: git fetch, then fast-forward it to GitHub's (pull.ts)
|
|
70
74
|
*
|
|
71
75
|
* `base` is the branch the others are compared with: the repository's
|
|
72
76
|
* default branch unless set. Builds are served without the token, as a frame
|
|
73
77
|
* can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
|
|
74
78
|
*
|
|
79
|
+
* The viewer has the token from the link the dev server logs, or asks
|
|
80
|
+
* `/connect` for it from its own page. Only a page of an allowed origin gets
|
|
81
|
+
* it: the browser names the page's origin, and no site can claim another's. A
|
|
82
|
+
* request that names none, as a page's request to its own origin, gets none.
|
|
83
|
+
*
|
|
75
84
|
* A fake API beside the app is declared in `workbench.json` at the
|
|
76
85
|
* repository's top, as Proto's `api`: its module from Proto's `folder`, whose
|
|
77
86
|
* default export is a fetch handler, asked at `/api`. The dev server answers
|
|
@@ -83,12 +92,21 @@ function open(url) {
|
|
|
83
92
|
*
|
|
84
93
|
* The `api` option overrides the file: `entry`, the module from the app's
|
|
85
94
|
* folder, and `path`, where the app asks it (`/api` unless set).
|
|
95
|
+
*
|
|
96
|
+
* The dev server serves https, so Safari, which blocks a secure page from
|
|
97
|
+
* reaching `http://localhost`, can use it too. Its certificate is Vite's own
|
|
98
|
+
* basic-ssl's, one for every project on this computer, which a browser
|
|
99
|
+
* trusts once (https.ts). `https: false`, or WORKBENCH_HTTPS=0, serves http;
|
|
100
|
+
* WORKBENCH_HTTPS=1 serves https whatever the option says. Under Vitest it is
|
|
101
|
+
* http unless `https` is set, and an app's own `server.https` is left as is.
|
|
86
102
|
*/
|
|
87
103
|
export function workbenchLocal(options = {}) {
|
|
88
104
|
let root = process.cwd();
|
|
89
105
|
let git = gitIn(root);
|
|
90
106
|
let top = null;
|
|
91
107
|
let server = null;
|
|
108
|
+
let certificate = null;
|
|
109
|
+
const https = environment.https ?? options.https ?? !environment.vitest;
|
|
92
110
|
const given = options.api;
|
|
93
111
|
const option = given
|
|
94
112
|
? {
|
|
@@ -160,6 +178,15 @@ export function workbenchLocal(options = {}) {
|
|
|
160
178
|
await git('fetch', '--prune', 'origin');
|
|
161
179
|
return sendJson(response, 200, { ok: true });
|
|
162
180
|
}
|
|
181
|
+
if (request.method === 'POST' && path === '/pull') {
|
|
182
|
+
const branch = (await bodyOf(request))?.branch;
|
|
183
|
+
if (typeof branch !== 'string' || !REF.test(branch)) {
|
|
184
|
+
return sendJson(response, 400, { error: 'Bad branch' });
|
|
185
|
+
}
|
|
186
|
+
const top = (await topOf()) ?? root;
|
|
187
|
+
const result = await pull(git, top, branch, () => void server?.restart());
|
|
188
|
+
return sendJson(response, result.status, result.body);
|
|
189
|
+
}
|
|
163
190
|
return sendJson(response, 404, { error: 'Not found' });
|
|
164
191
|
}
|
|
165
192
|
/** A build's own fake API, asked at `rest`, the path under the build; false when it has none there. */
|
|
@@ -218,6 +245,9 @@ export function workbenchLocal(options = {}) {
|
|
|
218
245
|
response.statusCode = 204;
|
|
219
246
|
return response.end();
|
|
220
247
|
}
|
|
248
|
+
if (request.method === 'GET' && url.pathname === '/connect' && allowed(origin)) {
|
|
249
|
+
return sendJson(response, 200, { token, repo: await repositoryOf(git) });
|
|
250
|
+
}
|
|
221
251
|
if (!allowed(origin) || request.headers['x-workbench-token'] !== token) {
|
|
222
252
|
return sendJson(response, 403, {
|
|
223
253
|
error: 'Only the viewer, with the token from the dev server',
|
|
@@ -233,8 +263,15 @@ export function workbenchLocal(options = {}) {
|
|
|
233
263
|
return {
|
|
234
264
|
name: 'workbench-local',
|
|
235
265
|
apply: 'serve',
|
|
236
|
-
|
|
237
|
-
|
|
266
|
+
async config(config) {
|
|
267
|
+
// Builds and their worktrees are no part of the app: never watch or reload for them.
|
|
268
|
+
const ignored = { watch: { ignored: ['**/.builds/**'] } };
|
|
269
|
+
if (!https || config.server?.https) {
|
|
270
|
+
return { server: ignored };
|
|
271
|
+
}
|
|
272
|
+
certificate = await localhostCertificate();
|
|
273
|
+
return { server: { ...ignored, https: { key: certificate.pem, cert: certificate.pem } } };
|
|
274
|
+
},
|
|
238
275
|
configResolved(config) {
|
|
239
276
|
root = config.root;
|
|
240
277
|
git = gitIn(root);
|
|
@@ -266,16 +303,20 @@ export function workbenchLocal(options = {}) {
|
|
|
266
303
|
},
|
|
267
304
|
};
|
|
268
305
|
async function announce(devServer) {
|
|
306
|
+
const address = devServer.httpServer?.address();
|
|
307
|
+
const port = typeof address === 'object' && address ? address.port : 5173;
|
|
308
|
+
// How to trust the certificate, once, before the viewer is opened on it.
|
|
309
|
+
if (certificate) {
|
|
310
|
+
devServer.config.logger.info(`\n ${trustSteps(certificate.file, port).join('\n ')}`);
|
|
311
|
+
}
|
|
269
312
|
if (!environment.viewerUrl) {
|
|
270
313
|
devServer.config.logger.info('\n Viewer: set WORKBENCH_URL to its address to open it\n');
|
|
271
314
|
return;
|
|
272
315
|
}
|
|
273
|
-
const address = devServer.httpServer?.address();
|
|
274
|
-
const port = typeof address === 'object' && address ? address.port : 5173;
|
|
275
316
|
// The repository, as owner/name: the viewer opens its project from it.
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
const local = encodeURIComponent(
|
|
317
|
+
const repository = await repositoryOf(git);
|
|
318
|
+
const scheme = devServer.config.server.https ? 'https' : 'http';
|
|
319
|
+
const local = encodeURIComponent(`${scheme}://localhost:${port}`);
|
|
279
320
|
const url = `${environment.viewerUrl}/?src=local&v=live#local=${local}&token=${token}${repository ? `&repo=${encodeURIComponent(repository)}` : ''}`;
|
|
280
321
|
devServer.config.logger.info(`\n Viewer: ${url}\n`);
|
|
281
322
|
// `pnpm dev` opens it; any other script is the app alone.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tilewise/workbench-client",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Connects a React prototype to the workbench's viewer: its screens, its controls and the protocol between them.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"vitest": "^4.1.11"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
+
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
|
57
58
|
"modern-screenshot": "^4.7.0"
|
|
58
59
|
},
|
|
59
60
|
"scripts": {
|