Skip to content

Commit f65b7b2

Browse files
committed
workspace, examples/artifacts, docs: add artifacts share shorthand
Handing someone a clone link still took two steps and a manual URL assembly: mint a read token with token create, then splice the token into the repo's remote by hand. Add a top-level `artifacts share` that does both and prints just the credentialed remote URL on stdout, one clone/push-ready string with no JSON envelope. Scope defaults to read, the common case for a fetch-only link; pass --scope write for a pushable URL. --ttl takes the same duration grammar as create. The repo must already exist, so a missing repo fails before any token is minted. It is the read-side counterpart to create: where create sets up a repo and a push remote, share hands out a URL to clone from. Collapse the example's token-create-plus-hand-built-URL onto the new command and document the shorthand.
1 parent 01d4d94 commit f65b7b2

8 files changed

Lines changed: 264 additions & 62 deletions

File tree

docs/15_artifacts_interface.md

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,14 @@ argv-driven CLI backed by one implementation, so they cannot drift.
6969
Every flag-shape decision lives in `artifacts/cli.ts`; the typed
7070
methods and the CLI route to the same client.
7171

72-
The one exception is the CLI's top-level `create` shorthand, which
73-
composes `create` and `createToken` and then registers a git remote.
74-
The composition and the git step are CLI-only: there is no single
75-
typed method for them, and the git step rides on an injected seam
76-
(below) rather than a client method. The pieces it composes are still
77-
the same client methods, so the two doors do not drift on the parts
78-
they share.
72+
The exceptions are the CLI's top-level shorthands. `create` composes
73+
`create` and `createToken` and then registers a git remote; `share`
74+
composes `get` and `createToken` and prints a single credentialed
75+
URL. These compositions are CLI-only — there is no single typed
76+
method for them, and `create`'s git step rides on an injected seam
77+
(below) rather than a client method. The pieces they compose are
78+
still the same client methods, so the two doors do not drift on the
79+
parts they share.
7980

8081
## Typed surface
8182

@@ -115,8 +116,8 @@ result type carries a page of tokens plus a `total`. So
115116

116117
## CLI surface
117118

118-
`artifacts.cli({ argv })` dispatches a top-level `create` shorthand
119-
plus two groups, `repo` and `token`.
119+
`artifacts.cli({ argv })` dispatches two top-level shorthands,
120+
`create` and `share`, plus two groups, `repo` and `token`.
120121

121122
```
122123
artifacts help # top-level help
@@ -126,6 +127,7 @@ artifacts token --help # token group help
126127
127128
artifacts create <name> [--scope read|write] [--ttl DUR] [--remote NAME] \
128129
[--default-branch B] [--description D] [--force]
130+
artifacts share <name> [--scope read|write] [--ttl DUR]
129131
130132
artifacts repo create <name> [--description D] [--default-branch B] [--read-only]
131133
artifacts repo get <name>
@@ -141,8 +143,9 @@ artifacts token delete <repo> <id|plaintext> # alias: revoke
141143

142144
Output is machine-first. Reads and data-producing mutations
143145
(`create`, `repo create`, `get`, `list`, `import`,
144-
`token create/list/get`) print JSON on stdout. `delete` and
145-
`token delete` print a one-line confirmation.
146+
`token create/list/get`) print JSON on stdout. `share` prints a
147+
single credentialed remote URL. `delete` and `token delete` print a
148+
one-line confirmation.
146149

147150
### The `create` shorthand
148151

@@ -185,6 +188,24 @@ same `workspace.git.cli(...)` the built-in `git` command uses. The
185188
typed `create`/`createToken` methods never learn about git, so the
186189
JS API and the CLI cannot drift.
187190

191+
### The `share` shorthand
192+
193+
`artifacts share <name>` is the read-side counterpart to `create`.
194+
It mints a git token for an existing repo and prints just the
195+
credentialed remote URL on stdout — one clone/push-ready string, no
196+
JSON envelope — so a caller can hand off a link without parsing
197+
output or hand-building the URL.
198+
199+
- `--scope` defaults to `read`: the common case is handing a
200+
fetch-only link to a consumer. Pass `--scope write` for a
201+
pushable URL.
202+
- `--ttl` takes the same duration grammar as `create`.
203+
204+
The repo must already exist; a missing repo is a hard error (exit 1)
205+
and no token is minted. The whole printed URL is a secret — it
206+
carries a live token. Each call mints a fresh token, so revoking one
207+
shared link does not disturb others.
208+
188209
Help is a first-class, agent-readable surface. `help`, `--help`,
189210
`-h`, and each group's `--help` print documentation that spells out
190211
the session-scoping contract and the secret-handling rules. A bare
@@ -201,8 +222,9 @@ the session-scoping contract and the secret-handling rules. A bare
201222

202223
### Secrets
203224

204-
`token create` and the top-level `create` shorthand print a token's
205-
`plaintext`, and `repo create` / `import` return an initial `token`.
225+
`token create` and the `create` shorthand print a token's
226+
`plaintext`; `share` prints a remote URL with a live token embedded;
227+
and `repo create` / `import` return an initial `token`.
206228
The `create` shorthand additionally prints a `credentialedRemote`
207229
URL with that token embedded — treat the whole URL as a secret.
208230
`token list` and `token get` show metadata only. Capture a token's

examples/artifacts/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ The Worker endpoint owns the orchestration. The durable object stays minimal: it
4040
4. initializes and commits the generated project with the shell `git` command;
4141
5. runs `artifacts create <name> --remote origin --force` — one command that creates the session-scoped Artifact repo, mints a write token, and registers the credentialed remote as `origin`;
4242
6. pushes `HEAD:main` to `origin`;
43-
7. creates a short-lived read token with the shell `artifacts` command and returns a clone command.
43+
7. runs `artifacts share <name> --scope read` — one command that mints a short-lived read token and returns a single clone-ready URL — and returns a clone command built from it.
4444

4545
A successful response looks like:
4646

@@ -52,8 +52,7 @@ A successful response looks like:
5252
"branch": "main",
5353
"projectDir": "/workspace/my-generated-worker",
5454
"shareLink": "https://x:<token>@<account>.artifacts.cloudflare.net/git/workspace-artifacts-example/<repo>.git",
55-
"cloneCommand": "git clone 'https://x:<token>@<account>.artifacts.cloudflare.net/git/workspace-artifacts-example/<repo>.git' my-generated-worker",
56-
"tokenExpiresAt": "2026-06-17T00:00:00.000Z"
55+
"cloneCommand": "git clone 'https://x:<token>@<account>.artifacts.cloudflare.net/git/workspace-artifacts-example/<repo>.git' my-generated-worker"
5756
}
5857
```
5958

examples/artifacts/src/index.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ interface CreateResult {
3131
projectDir: string;
3232
shareLink: string;
3333
cloneCommand: string;
34-
tokenExpiresAt: string;
3534
}
3635

3736
interface ArtifactCreateOutput {
@@ -41,11 +40,6 @@ interface ArtifactCreateOutput {
4140
credentialedRemote: string;
4241
}
4342

44-
interface TokenCreateOutput {
45-
plaintext: string;
46-
expiresAt: string;
47-
}
48-
4943
const WORKSPACE_ROOT = "/workspace";
5044
const SOURCE_REPO = "https://github.com/cloudflare/workspace";
5145
const EXAMPLE_PATH = "examples/worker";
@@ -185,13 +179,12 @@ async function handleCreate(request: Request, env: Env): Promise<Response> {
185179
secretToRedact: created.credentialedRemote,
186180
});
187181

188-
const readToken = parseJSON<TokenCreateOutput>(
189-
await exec(
190-
ws,
191-
`artifacts token create ${shellQuote(name)} --scope read --ttl ${SHARE_TOKEN_TTL}`,
192-
),
193-
);
194-
const shareLink = authenticatedArtifactRemote(created.remote, readToken.plaintext);
182+
// `artifacts share` mints a read token and returns one
183+
// clone-ready URL, so there is nothing to hand-assemble. The URL
184+
// carries a live token — redact it from any error output.
185+
const shareLink = (
186+
await exec(ws, `artifacts share ${shellQuote(name)} --scope read --ttl ${SHARE_TOKEN_TTL}`)
187+
).trim();
195188

196189
return Response.json({
197190
name,
@@ -201,7 +194,6 @@ async function handleCreate(request: Request, env: Env): Promise<Response> {
201194
projectDir,
202195
shareLink,
203196
cloneCommand: `git clone ${shellQuote(shareLink)} ${shellQuote(name)}`,
204-
tokenExpiresAt: readToken.expiresAt,
205197
} satisfies CreateResult);
206198
} catch (cause) {
207199
return errorJSON(cause, isAlreadyExists(cause) ? 409 : 500);
@@ -246,11 +238,6 @@ function isAlreadyExists(cause: unknown): boolean {
246238
);
247239
}
248240

249-
function authenticatedArtifactRemote(remote: string, token: string): string {
250-
const secret = token.split("?expires=", 1)[0];
251-
return `https://x:${encodeURIComponent(secret)}@${remote.slice("https://".length)}`;
252-
}
253-
254241
function errorJSON(error: unknown, status: number): Response {
255242
const message = error instanceof Error ? error.message : String(error);
256243
const code = (error as { code?: string }).code;

packages/workspace/src/artifacts/cli.test.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { beforeEach, describe, expect, it } from "vitest";
88
import { FakeArtifactsBinding } from "../../tests/utilities/fake-artifacts-binding.js";
9-
import { credentialUrl } from "./cli.js";
9+
import { credentialURL } from "./cli.js";
1010
import { createArtifact } from "./client.js";
1111

1212
function makeClient() {
@@ -20,9 +20,9 @@ async function run(argv: string[]) {
2020
return client.cli({ argv });
2121
}
2222

23-
describe("credentialUrl", () => {
23+
describe("credentialURL", () => {
2424
it("embeds the token as basic-auth with the conventional x user", () => {
25-
expect(credentialUrl("https://acct.example.net/git/repo.git", "art_v1_abc")).toBe(
25+
expect(credentialURL("https://acct.example.net/git/repo.git", "art_v1_abc")).toBe(
2626
"https://x:art_v1_abc@acct.example.net/git/repo.git",
2727
);
2828
});
@@ -31,14 +31,21 @@ describe("credentialUrl", () => {
3131
// The real binding's plaintext carries a trailing
3232
// `?expires=<ts>`; folding it in verbatim would corrupt the
3333
// password and the remote would 401.
34-
const url = credentialUrl(
34+
const url = credentialURL(
3535
"https://acct.example.net/git/repo.git",
3636
"art_v1_secret?expires=1700000000",
3737
);
3838
expect(url).toBe("https://x:art_v1_secret@acct.example.net/git/repo.git");
3939
expect(url).not.toContain("expires");
4040
expect(new URL(url).password).toBe("art_v1_secret");
4141
});
42+
43+
it("splices userinfo textually without round-tripping through new URL", () => {
44+
// The remote may be a form workerd's URL parser rejects; the
45+
// helper must not depend on `new URL` parsing it.
46+
const url = credentialURL("https://h.example.net/git/ns/repo.git", "art_v1_x");
47+
expect(url).toBe("https://x:art_v1_x@h.example.net/git/ns/repo.git");
48+
});
4249
});
4350

4451
describe("runArtifactsCLI", () => {
@@ -227,6 +234,66 @@ describe("runArtifactsCLI", () => {
227234
});
228235
});
229236

237+
// `share` mints a token for an existing repo and prints just the
238+
// credentialed remote URL on stdout — a single clone/push-ready
239+
// string a caller can hand off without parsing JSON.
240+
describe("share (shorthand)", () => {
241+
it("prints a single credentialed remote URL", async () => {
242+
await client.cli({ argv: ["repo", "create", "starter"] });
243+
const res = await client.cli({ argv: ["share", "starter", "--scope", "read"] });
244+
expect(res.exitCode).toBe(0);
245+
const url = res.stdout.trim();
246+
// One line, no JSON envelope.
247+
expect(url.split("\n")).toHaveLength(1);
248+
expect(url.startsWith("https://x:")).toBe(true);
249+
expect(url).toContain("art_v1_");
250+
expect(url).toContain("sess1__starter.git");
251+
// The expires hint must not leak into the password.
252+
expect(url).not.toContain("?expires=");
253+
const info = await binding.get("sess1__starter");
254+
expect(url).toBe(credentialURL(info.remote, new URL(url).password));
255+
});
256+
257+
it("defaults the scope to read", async () => {
258+
await client.cli({ argv: ["repo", "create", "starter"] });
259+
const res = await client.cli({ argv: ["share", "starter"] });
260+
expect(res.exitCode).toBe(0);
261+
const tokens = await binding.get("sess1__starter").then((r) => r.listTokens());
262+
// Two tokens: the repo-create initial write token, plus this
263+
// read share token.
264+
const shared = tokens.tokens.find((t) => t.scope === "read");
265+
expect(shared).toBeDefined();
266+
});
267+
268+
it("accepts a unit-suffixed --ttl", async () => {
269+
await client.cli({ argv: ["repo", "create", "starter"] });
270+
const res = await client.cli({ argv: ["share", "starter", "--ttl", "30m"] });
271+
expect(res.exitCode).toBe(0);
272+
});
273+
274+
it("exits 129 on a malformed --ttl", async () => {
275+
await client.cli({ argv: ["repo", "create", "starter"] });
276+
const res = await client.cli({ argv: ["share", "starter", "--ttl", "5w"] });
277+
expect(res.exitCode).toBe(129);
278+
});
279+
280+
it("exits 129 on an invalid scope", async () => {
281+
await client.cli({ argv: ["repo", "create", "starter"] });
282+
const res = await client.cli({ argv: ["share", "starter", "--scope", "admin"] });
283+
expect(res.exitCode).toBe(129);
284+
});
285+
286+
it("exits 129 when the name is missing", async () => {
287+
const res = await client.cli({ argv: ["share"] });
288+
expect(res.exitCode).toBe(129);
289+
});
290+
291+
it("exits 1 when the repo does not exist", async () => {
292+
const res = await client.cli({ argv: ["share", "ghost"] });
293+
expect(res.exitCode).toBe(1);
294+
});
295+
});
296+
230297
describe("token list", () => {
231298
it("prints token metadata without any plaintext", async () => {
232299
await client.cli({ argv: ["repo", "create", "starter"] });

0 commit comments

Comments
 (0)