Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ dist/
# deploy project's directory.
.scratchwork-data/
.scratchwork-local-data/
.scratchwork-cloudflare-data/
.wrangler/
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ scratchwork dev page.md
scratchwork dev page.html
```

When developing the CLI itself, activate this checkout once in each terminal
session:

```sh
source ./cli/activate-scratchwork-alias
```

After that, `scratchwork` runs `cli/src/index.ts` from this checkout, even after
changing to another directory.

## Working with Markdown

Scratchwork renders Markdown with an embedded default renderer, and Markdown files can reference React components from nearby component files. See [`docs/index.md`](docs/index.md) for live examples.
Expand Down Expand Up @@ -78,14 +88,21 @@ Run the publishing server locally:
bun run local:local-dev
```

To run the actual Cloudflare Worker with persistent local R2 and D1 simulations (and
an optional locally signed Cloudflare Access identity), see
[`server/deploy-cloudflare/README.md`](server/deploy-cloudflare/README.md).

The ready-made Access test deployment is `bun run local:cloudflare-access`; the sndbx.sh
project's production Worker configuration runs locally with `bun run local:cloudflare-vanilla`.

Then publish a directory or file:

```sh
scratchwork login --server http://localhost:43118
scratchwork publish index.html
```

The server stores immutable file blobs in object storage and mutable project metadata in its database. The CLI saves `server`, `project`, `visibility`, and the latest URL in `.scratchwork.json` so the next `scratchwork publish` updates the same project.
The server stores immutable file blobs in object storage and mutable project metadata in its database. The CLI saves `server`, `project`, `isPublic`, and the latest URL in `.scratchwork.json` so the next `scratchwork publish` updates the same project.

Share a published project with specific accounts or a whole domain — as readers, writers (can publish updates), or admins (can also manage sharing) — or take access away again:

Expand All @@ -98,16 +115,16 @@ scratchwork revoke alice@example.com
Deployments live as projects under `deploy/`, one per domain, each deployable with one command:

```sh
bun run deploy:sndbx.sh
bun run deploy:cloudflare-vanilla
```

Cloud runtime dependencies live in `server/deploy-aws` and `server/deploy-cloudflare`. See `server/README.md` for cloud setup details.

Deploy secrets load from the project's `.env`:

```sh
cp deploy/sndbx.sh/.env.example deploy/sndbx.sh/.env
bun run deploy:sndbx.sh
cp deploy/cloudflare-vanilla/.env.example deploy/cloudflare-vanilla/.env
bun run deploy:cloudflare-vanilla
```

---
Expand Down
290 changes: 277 additions & 13 deletions bun.lock

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions cli/activate-scratchwork-alias
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Source this file to run the checkout's CLI as `scratchwork` in this shell.

if [ -n "${ZSH_VERSION:-}" ]; then
SCRATCHWORK_DEV_ROOT="${${(%):-%N}:A:h:h}"
elif [ -n "${BASH_VERSION:-}" ]; then
SCRATCHWORK_DEV_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
else
echo "scratchwork: cli/activate-scratchwork-alias supports zsh and bash" >&2
return 1
fi

scratchwork() {
command bun "$SCRATCHWORK_DEV_ROOT/cli/src/index.ts" "$@"
}

echo "scratchwork: activated $SCRATCHWORK_DEV_ROOT/cli/src/index.ts"
65 changes: 60 additions & 5 deletions cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as HttpClientRequest from "@effect/platform/HttpClientRequest";
import type * as Path from "@effect/platform/Path";
import * as Effect from "effect/Effect";
import { isRecord, parseJson } from "../../shared/src/util/json";
import { readAuthToken, serverApiUrl } from "./auth";
import { readAuthToken, readCfToken, serverApiUrl } from "./auth";
import { CliError, errorMessage } from "./errors";

/** A completed API exchange: HTTP status plus the raw and JSON-decoded body. */
Expand All @@ -40,21 +40,30 @@ export interface ResolvedProjectRef {
/**
* Executes one JSON API request. Transport failures (unreachable server,
* aborted response) fail with a context-prefixed CliError; HTTP error statuses
* are returned in the ApiResponse for the caller to interpret. Requests are
* interruption-safe: Ctrl-C aborts the in-flight request.
* are returned in the ApiResponse for the caller to interpret — except a
* Cloudflare Access edge block, which fails with a re-auth hint since no
* caller can act on it. Requests are interruption-safe: Ctrl-C aborts the
* in-flight request.
*/
export function apiRequest(
context: string,
url: URL | string,
options: ApiRequestOptions = {},
): Effect.Effect<ApiResponse, CliError, HttpClient.HttpClient> {
): Effect.Effect<ApiResponse, CliError, HttpClient.HttpClient | FileSystem.FileSystem | Path.Path> {
return Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
let request = HttpClientRequest.make(options.method ?? "GET")(url);
if (options.token != null) request = HttpClientRequest.bearerToken(request, options.token);
request = yield* attachCloudflareAccess(request, url);
if (options.body !== undefined) request = HttpClientRequest.bodyUnsafeJson(request, options.body);
const response = yield* client.execute(request);
const text = yield* response.text;
if (edgeBlocked(response.status, response.headers, text)) {
return yield* apiFail(
context,
"Cloudflare Access blocked this request. Run `scratchwork login` again (your Access session may have expired), or set SCRATCHWORK_CF_ACCESS_CLIENT_ID and SCRATCHWORK_CF_ACCESS_CLIENT_SECRET for automation.",
);
}
return {
status: response.status,
ok: response.status >= 200 && response.status < 300,
Expand All @@ -74,14 +83,60 @@ export function apiJson(
context: string,
url: URL | string,
options: ApiRequestOptions = {},
): Effect.Effect<unknown, CliError, HttpClient.HttpClient> {
): Effect.Effect<unknown, CliError, HttpClient.HttpClient | FileSystem.FileSystem | Path.Path> {
return Effect.gen(function* () {
const response = yield* apiRequest(context, url, options);
if (!response.ok) return yield* apiFail(context, apiErrorText(response));
return response.json;
});
}

/**
* Attaches Cloudflare Access credentials so requests pass an Access-protected
* edge: the Access JWT the server relayed at login (stored per origin, sent as
* `cf-access-token`), and the service-token headers from
* SCRATCHWORK_CF_ACCESS_CLIENT_ID/SECRET for CI and headless automation.
* Cloudflare validates either at the edge; the server still identifies the
* user by the bearer token. Servers without Access ignore the extra headers.
*/
function attachCloudflareAccess(
request: HttpClientRequest.HttpClientRequest,
url: URL | string,
): Effect.Effect<HttpClientRequest.HttpClientRequest, CliError, FileSystem.FileSystem | Path.Path> {
return Effect.gen(function* () {
let result = request;
const cfToken = yield* readCfToken(new URL(url).origin);
if (cfToken != null) result = HttpClientRequest.setHeader(result, "cf-access-token", cfToken);
const clientId = process.env.SCRATCHWORK_CF_ACCESS_CLIENT_ID;
const clientSecret = process.env.SCRATCHWORK_CF_ACCESS_CLIENT_SECRET;
if (clientId != null && clientId !== "" && clientSecret != null && clientSecret !== "") {
result = HttpClientRequest.setHeaders(result, {
"CF-Access-Client-Id": clientId,
"CF-Access-Client-Secret": clientSecret,
});
}
return result;
});
}

/** Matches Cloudflare Access artifacts in an HTML body where JSON was expected. */
const ACCESS_PAGE_MARKERS = /cloudflareaccess|CF_Authorization/i;

/**
* Detects a request Cloudflare's edge blocked before it reached the server: a
* 403 the edge tags with `cf-mitigated`, or an Access login page — HTML with
* Access markers — where the API would have answered with JSON (the shape a
* 302 to the login page takes after redirect following).
*/
function edgeBlocked(
status: number,
headers: Readonly<Record<string, string | undefined>>,
text: string,
): boolean {
if (status === 403 && headers["cf-mitigated"] != null) return true;
return /^\s*<(!doctype|html)/i.test(text) && ACCESS_PAGE_MARKERS.test(text);
}

/** Extracts the most useful error text from a failed response: the JSON `error` field
* when the server sent one, otherwise a short summary. Non-JSON bodies (Cloudflare or
* proxy HTML error pages, stack dumps) are never echoed wholesale into the terminal —
Expand Down
25 changes: 24 additions & 1 deletion cli/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ import { CliError, errorMessage } from "./errors";
const AUTH_FILE = "auth.json";
const DEFAULT_APP_SUBDOMAIN = "app";

/** One stored login: the bearer token for a server plus who/when it was issued. */
/** One stored login: the bearer token for a server plus who/when it was issued.
* `cfToken` is the Cloudflare Access JWT relayed by a cloudflare-access server at
* login; the CLI presents it on API requests so they pass Cloudflare's edge. */
export interface AuthRecord {
readonly token: string;
readonly email?: string;
readonly cfToken?: string;
readonly updatedAt: string;
}

Expand All @@ -36,6 +39,7 @@ export interface LoginCallback {
readonly token: string;
readonly email?: string;
readonly server?: string;
readonly cfToken?: string;
}

/**
Expand All @@ -58,11 +62,27 @@ export function readAuthToken(
});
}

/** Looks up the stored Cloudflare Access JWT for a server, with the same origin
* fallbacks as readAuthToken. Undefined for servers that did not relay one. */
export function readCfToken(
server: string,
): Effect.Effect<string | undefined, CliError, FileSystem.FileSystem | Path.Path> {
return Effect.gen(function* () {
const auth = yield* readAuthFile();
for (const candidate of candidateServers(server)) {
const cfToken = auth.servers[candidate]?.cfToken;
if (cfToken != null) return cfToken;
}
return undefined;
});
}

/** Saves a bearer token for a server, preserving tokens stored for other servers. */
export function writeAuthToken(
server: string,
token: string,
email: string | undefined,
cfToken?: string,
): Effect.Effect<void, PlatformError | CliError, FileSystem.FileSystem | Path.Path> {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand All @@ -76,6 +96,7 @@ export function writeAuthToken(
[server]: {
token,
email,
cfToken,
updatedAt: new Date().toISOString(),
},
},
Expand Down Expand Up @@ -128,6 +149,7 @@ export function decodeLoginCallback(url: URL): LoginCallback | null {
token,
email: nonEmpty(url.searchParams.get("email") ?? undefined),
server: nonEmpty(url.searchParams.get("server") ?? undefined),
cfToken: nonEmpty(url.searchParams.get("cf_token") ?? undefined),
};
}

Expand Down Expand Up @@ -200,6 +222,7 @@ function isAuthFile(value: unknown): value is AuthFile {
for (const record of Object.values(value.servers)) {
if (!isRecord(record) || typeof record.token !== "string" || typeof record.updatedAt !== "string") return false;
if (record.email != null && typeof record.email !== "string") return false;
if (record.cfToken != null && typeof record.cfToken !== "string") return false;
}
return true;
}
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function runLogin(
try: () => normalizeServerUrl(result.server ?? server),
catch: () => new CliError({ code: 1, message: `scratchwork login: server returned an invalid server URL: ${result.server}` }),
});
yield* writeAuthToken(authenticatedServer, result.token, result.email);
yield* writeAuthToken(authenticatedServer, result.token, result.email, result.cfToken);
yield* Console.log(`Authenticated ${result.email ?? "user"} for ${authenticatedServer}`);
});
}
Expand Down
6 changes: 3 additions & 3 deletions cli/src/commands/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { runPublish, SKIPPED_DIRECTORIES, type PublishServices } from "./publish
/** Project metadata as returned by /api/projects. */
interface ApiProject {
readonly project: string;
readonly visibility: string;
readonly isPublic: boolean;
readonly url?: string;
readonly updatedAt: string;
}
Expand Down Expand Up @@ -59,7 +59,7 @@ export function runProjects(
return;
}
yield* Console.log(projects.map((project) =>
`${project.project}\t${project.visibility}\t${project.url ?? `/${project.project}/`}`,
`${project.project}\t${project.isPublic ? "public" : "private"}\t${project.url ?? `/${project.project}/`}`,
).join("\n"));
});
}
Expand All @@ -76,7 +76,7 @@ export function runInfo(
});
}

/** Runs `scratchwork unpublish`: sets a project's visibility to private. */
/** Runs `scratchwork unpublish`: makes a project private and clears every grant. */
export function runUnpublish(
config: ProjectRefConfig,
): Effect.Effect<void, PlatformError | CliError, ProjectServices> {
Expand Down
22 changes: 11 additions & 11 deletions cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export type PublishServices = CommandExecutor | FileSystem.FileSystem | HttpClie
* random-naming server it is how the CLI learns the assigned name. */
interface PublishResponse {
readonly project: string;
readonly visibility: string;
readonly isPublic: boolean;
readonly openPath: string;
readonly url: string;
}
Expand All @@ -67,16 +67,16 @@ export function runPublish(
const authToken = yield* readAuthToken(server);
const project = yield* resolveProjectName(config, projectConfig, target);
const nameSource = target.file ?? (yield* basename(target.root));
// An omitted visibility lets the server preserve an existing project's visibility
// or apply its default; an omitted project lets a random-naming server mint one.
const visibility = nonEmpty(config.visibility) ?? nonEmpty(projectConfig?.visibility);
// An omitted isPublic lets the server preserve an existing project's setting or
// apply its default; an omitted project lets a random-naming server mint one.
const isPublic = config.isPublic ?? projectConfig?.isPublic;

const bundle = yield* createBundle(target.root);
const body = {
bundle,
openPath: target.openPath,
project,
visibility,
isPublic,
};
const response = yield* postPublish(server, body, authToken).pipe(
Effect.catchIf((error) => error instanceof PublishAuthRequired, () =>
Expand Down Expand Up @@ -184,10 +184,10 @@ function postPublish(
readonly bundle: PublishBundle;
readonly openPath: string;
readonly project?: string;
readonly visibility?: string;
readonly isPublic?: boolean;
},
authToken: string | undefined,
): Effect.Effect<PublishResponse, CliError, HttpClient.HttpClient> {
): Effect.Effect<PublishResponse, CliError, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path> {
return Effect.gen(function* () {
const response = yield* apiRequest("scratchwork publish", serverApiUrl(server, "/api/publish"), {
method: "POST",
Expand Down Expand Up @@ -230,7 +230,7 @@ function writeMetadata(
return writeProjectConfig(root, {
server,
project: response.project,
visibility: response.visibility,
isPublic: response.isPublic,
url: response.url,
updatedAt: new Date().toISOString(),
}).pipe(
Expand Down Expand Up @@ -260,7 +260,7 @@ function printResult(
...(sentProject != null && sentProject !== response.project
? [` note server assigned project name "${response.project}"`]
: []),
` access ${response.visibility}`,
` access ${response.isPublic ? "public" : "private"}`,
` files ${bundle.files.length} (${formatBytes(bytes)})`,
...(saved ? [` saved ${PROJECT_CONFIG_FILE}\n`] : [""]),
].join("\n"),
Expand All @@ -272,15 +272,15 @@ function decodePublishResponse(value: unknown): PublishResponse | null {
if (!isRecord(value)) return null;
if (
typeof value.project !== "string" ||
typeof value.visibility !== "string" ||
typeof value.isPublic !== "boolean" ||
typeof value.openPath !== "string" ||
typeof value.url !== "string"
) {
return null;
}
return {
project: value.project,
visibility: value.visibility,
isPublic: value.isPublic,
openPath: value.openPath,
url: value.url,
};
Expand Down
Loading