@lunora/cli 1.0.0-alpha.217 → 1.0.0-alpha.219
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/bin.mjs +1 -1
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.mjs +1 -1
- package/dist/packem_chunks/handler.mjs +1 -1
- package/dist/packem_chunks/handler10.mjs +4 -1
- package/dist/packem_chunks/handler11.mjs +2 -4
- package/dist/packem_chunks/handler12.mjs +1 -2
- package/dist/packem_chunks/handler13.mjs +1 -1
- package/dist/packem_chunks/handler14.mjs +1 -1
- package/dist/packem_chunks/handler15.mjs +1 -1
- package/dist/packem_chunks/handler16.mjs +3 -1
- package/dist/packem_chunks/handler17.mjs +1 -3
- package/dist/packem_chunks/handler18.mjs +1 -1
- package/dist/packem_chunks/handler19.mjs +7 -1
- package/dist/packem_chunks/handler2.mjs +2 -1
- package/dist/packem_chunks/handler20.mjs +1 -7
- package/dist/packem_chunks/handler21.mjs +3 -1
- package/dist/packem_chunks/handler22.mjs +3 -2
- package/dist/packem_chunks/handler23.mjs +1 -3
- package/dist/packem_chunks/handler24.mjs +2 -3
- package/dist/packem_chunks/handler25.mjs +99 -1
- package/dist/packem_chunks/handler26.mjs +10 -99
- package/dist/packem_chunks/handler3.mjs +1 -2
- package/dist/packem_chunks/handler4.mjs +1 -1
- package/dist/packem_chunks/handler5.mjs +1 -1
- package/dist/packem_chunks/handler6.mjs +1 -1
- package/dist/packem_chunks/handler7.mjs +3 -1
- package/dist/packem_chunks/handler8.mjs +1 -3
- package/dist/packem_chunks/handler9.mjs +1 -1
- package/dist/packem_chunks/planDevCommand.mjs +6 -6
- package/dist/packem_chunks/runDeployCommand.mjs +1 -0
- package/dist/packem_chunks/runInitCommand.mjs +89 -63
- package/dist/packem_chunks/runMigrateGenerateCommand.mjs +3 -3
- package/dist/packem_shared/{COMMANDS-Cf45cyJN.mjs → COMMANDS-BbQ3-hLn.mjs} +1 -1
- package/dist/packem_shared/{DEFAULT_IMPORT_BATCH_SIZE-CYq3UJmD.mjs → DEFAULT_IMPORT_BATCH_SIZE-BPQYMEmV.mjs} +1 -1
- package/dist/packem_shared/{buildRegistryIndex-BcJgAx76.mjs → buildRegistryIndex-DVmy8fHE.mjs} +1 -1
- package/dist/packem_shared/catalog-Cbzph90w.mjs +1 -0
- package/dist/packem_shared/cli-DOBnG22n.mjs +3 -0
- package/dist/packem_shared/commands-CLLQuHqU.mjs +15 -0
- package/dist/packem_shared/{diffSnapshots-Cnoe8Ob4.mjs → diffSnapshots-DtC5Cs4Z.mjs} +3 -3
- package/dist/packem_shared/{import-8FCB0cUX.mjs → import-DuHtoh4_.mjs} +7 -7
- package/dist/packem_shared/resolve-BWFT7RvE.mjs +5 -0
- package/dist/packem_shared/runAddCommand-BABRWlOb.mjs +1 -0
- package/dist/packem_shared/schemaIrToSnapshot-VpPguG3W.mjs +1 -0
- package/dist/packem_shared/{storage-D5s4vbtJ.mjs → storage-DlUnv7uG.mjs} +1 -1
- package/dist/packem_shared/vectorize-metadata-dXpl2_Ar.mjs +1 -0
- package/package.json +11 -11
- package/skills/lunora-deploy/SKILL.md +42 -11
- package/skills/lunora-functions/SKILL.md +42 -14
- package/skills/lunora-migration-helper/SKILL.md +4 -4
- package/skills/lunora-performance-audit/SKILL.md +8 -5
- package/skills/lunora-quickstart/SKILL.md +3 -3
- package/skills/lunora-setup-auth/SKILL.md +23 -6
- package/skills/lunora-setup-hyperdrive/SKILL.md +33 -13
- package/skills/lunora-setup-mail/SKILL.md +34 -28
- package/skills/lunora-setup-scheduler/SKILL.md +15 -10
- package/skills/lunora-setup-storage/SKILL.md +182 -25
- package/dist/packem_chunks/handler27.mjs +0 -10
- package/dist/packem_shared/catalog-CJaormx0.mjs +0 -1
- package/dist/packem_shared/cli-CqNvbTR1.mjs +0 -3
- package/dist/packem_shared/commands-YfMX-Mto.mjs +0 -19
- package/dist/packem_shared/resolve-DZAGzg67.mjs +0 -1
- package/dist/packem_shared/runAddCommand-vE21wRIK.mjs +0 -1
- package/dist/packem_shared/runDeployCommand-o64lz-UD.mjs +0 -1
- package/dist/packem_shared/schemaIrToSnapshot-ogkcpHjM.mjs +0 -1
- package/dist/packem_shared/vectorize-metadata-DbziRKpX.mjs +0 -1
|
@@ -7,8 +7,9 @@ description: Adds transactional email to a Lunora app. Use for sending mail (ver
|
|
|
7
7
|
|
|
8
8
|
Wire transactional email into a Lunora app using the `mail` registry item, which
|
|
9
9
|
is built on `@lunora/mail` (a Cloudflare Email Workers transport with
|
|
10
|
-
header-injection-safe address handling) and exposes a `sendEmail`
|
|
11
|
-
fire-and-forget `queueEmail`
|
|
10
|
+
header-injection-safe address handling) and exposes a `sendEmail` `internalAction`
|
|
11
|
+
plus a fire-and-forget `queueEmail` `internalAction` — server-only, because a
|
|
12
|
+
client-callable general-purpose mailer is an open relay. In dev, every send is captured into the
|
|
12
13
|
Studio Mail tab instead of going out.
|
|
13
14
|
|
|
14
15
|
## When to Use
|
|
@@ -21,14 +22,14 @@ Studio Mail tab instead of going out.
|
|
|
21
22
|
|
|
22
23
|
- The project has no Lunora backend yet — use `lunora-quickstart` first.
|
|
23
24
|
- Mail is already installed and you just want to send — call
|
|
24
|
-
`ctx.runAction(
|
|
25
|
+
`ctx.runAction(internal.mail.sendEmail, …)` from a server handler.
|
|
25
26
|
|
|
26
27
|
## Workflow
|
|
27
28
|
|
|
28
29
|
1. Add the `mail` item.
|
|
29
30
|
2. Configure the `SEND_EMAIL` binding (or a provider) and `MAIL_FROM`.
|
|
30
31
|
3. Regenerate types with `lunora codegen`.
|
|
31
|
-
4. Send mail from a function
|
|
32
|
+
4. Send mail from a server function; render a React template if needed.
|
|
32
33
|
|
|
33
34
|
## Step 1: Add the item
|
|
34
35
|
|
|
@@ -40,8 +41,8 @@ This:
|
|
|
40
41
|
|
|
41
42
|
1. Adds `@lunora/mail` and `@lunora/server` to `package.json` (run
|
|
42
43
|
`pnpm install` afterwards).
|
|
43
|
-
2. Copies `lunora/mail/index.ts` (the `sendEmail` / `queueEmail`
|
|
44
|
-
your project — it is **yours** to edit.
|
|
44
|
+
2. Copies `lunora/mail/index.ts` (the `sendEmail` / `queueEmail`
|
|
45
|
+
**`internalAction`s**) into your project — it is **yours** to edit.
|
|
45
46
|
3. Adds a `send_email` binding (`SEND_EMAIL`, with a `destination_address`
|
|
46
47
|
placeholder) to `wrangler.jsonc` and scaffolds `MAIL_FROM` (the default
|
|
47
48
|
sender) into `.dev.vars`.
|
|
@@ -71,24 +72,27 @@ verification and forgot-password mail — is intercepted and surfaced in the
|
|
|
71
72
|
lunora codegen
|
|
72
73
|
```
|
|
73
74
|
|
|
74
|
-
The functions surface in the generated
|
|
75
|
-
`
|
|
75
|
+
The functions surface in the generated **`internal`** (server-only) namespace as
|
|
76
|
+
`internal.mail.sendEmail` and `internal.mail.queueEmail` — they are deliberately
|
|
77
|
+
**not** in the client-reachable `api`.
|
|
76
78
|
|
|
77
79
|
## Step 4: Send mail
|
|
78
80
|
|
|
79
81
|
### From another function
|
|
80
82
|
|
|
81
|
-
`sendEmail` is an
|
|
82
|
-
mutation, schedule it as a follow-up so the request is not blocked:
|
|
83
|
+
`sendEmail` is an **`internalAction`** (sending is non-transactional network
|
|
84
|
+
I/O). From a mutation, schedule it as a follow-up so the request is not blocked:
|
|
83
85
|
|
|
84
86
|
```ts
|
|
85
|
-
import {
|
|
87
|
+
import { internalMutation, v } from "#lunora/_generated/server.js";
|
|
86
88
|
|
|
87
|
-
import {
|
|
89
|
+
import { internal } from "./_generated/api";
|
|
88
90
|
|
|
89
|
-
export const inviteUser =
|
|
90
|
-
// ...persist the invite, then send the mail as a
|
|
91
|
-
|
|
91
|
+
export const inviteUser = internalMutation.input({ email: v.string() }).mutation(async ({ ctx, args: { email } }) => {
|
|
92
|
+
// ...authenticate the caller and persist the invite, then send the mail as a
|
|
93
|
+
// follow-up action. The recipient is decided server-side — never forward a
|
|
94
|
+
// client-chosen `to`/`from`/`html` straight through.
|
|
95
|
+
await ctx.scheduler.runAfter(0, internal.mail.sendEmail, {
|
|
92
96
|
to: email,
|
|
93
97
|
subject: "You're invited",
|
|
94
98
|
html: "<p>Click the link to join.</p>",
|
|
@@ -96,15 +100,15 @@ export const inviteUser = mutation.input({ email: v.string() }).mutation(async (
|
|
|
96
100
|
});
|
|
97
101
|
```
|
|
98
102
|
|
|
99
|
-
###
|
|
103
|
+
### Not from a client
|
|
100
104
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
})
|
|
107
|
-
|
|
105
|
+
There is no `client.action("mail/sendEmail", …)` path, and adding one is the
|
|
106
|
+
mistake this item exists to prevent: a general-purpose mailer that lets the
|
|
107
|
+
caller pick recipient, subject and body is an open relay for phishing through
|
|
108
|
+
your verified domain. If you need a client-callable send, write a
|
|
109
|
+
_purpose-specific_ public `action` that takes only safe business inputs (e.g.
|
|
110
|
+
`{ orderId }`), checks `ctx.auth`/RBAC, derives the recipient server-side,
|
|
111
|
+
rate-limits it (`@lunora/ratelimit`), and calls `internal.mail.sendEmail`.
|
|
108
112
|
|
|
109
113
|
### With a React email template
|
|
110
114
|
|
|
@@ -131,8 +135,10 @@ await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM a
|
|
|
131
135
|
1. **Expecting prod email to "just work".** Dev captures into the Studio;
|
|
132
136
|
production needs the `SEND_EMAIL` binding (a verified destination) or
|
|
133
137
|
`RESEND_API_KEY`.
|
|
134
|
-
2. **Calling `sendEmail` as a query/mutation.** It is an
|
|
135
|
-
`ctx.runAction` / `ctx.scheduler.runAfter`
|
|
138
|
+
2. **Calling `sendEmail` from the client, or as a query/mutation.** It is an
|
|
139
|
+
`internalAction` — invoke it via `ctx.runAction` / `ctx.scheduler.runAfter`
|
|
140
|
+
from a server handler. A client `client.action("mail/sendEmail", …)` is not
|
|
141
|
+
reachable and answers `FUNCTION_NOT_FOUND`.
|
|
136
142
|
3. **Using `queueEmail` without a Queue binding.** It requires a Cloudflare
|
|
137
143
|
Queue producer binding; until you add one, `@lunora/mail` throws
|
|
138
144
|
`` `queue` binding is required for mailer.queue() ``. The item does not add
|
|
@@ -145,7 +151,7 @@ await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM a
|
|
|
145
151
|
- [ ] `lunora registry add mail` run, `pnpm install` done.
|
|
146
152
|
- [ ] `SEND_EMAIL` binding configured (verified destination) or
|
|
147
153
|
`RESEND_API_KEY` set; `MAIL_FROM` set.
|
|
148
|
-
- [ ] `lunora codegen` run so `
|
|
149
|
-
- [ ] Mail sent from a function (`ctx.scheduler.runAfter
|
|
150
|
-
`
|
|
154
|
+
- [ ] `lunora codegen` run so `internal.mail.*` is generated.
|
|
155
|
+
- [ ] Mail sent from a server function (`ctx.scheduler.runAfter` / `ctx.runAction`
|
|
156
|
+
with `internal.mail.sendEmail`) — never from the client.
|
|
151
157
|
- [ ] Verified the send appears in the Studio Mail tab in dev.
|
|
@@ -31,11 +31,13 @@ which owns the alarm and durable storage.
|
|
|
31
31
|
|
|
32
32
|
## Deferred dispatch — `runAfter` / `runAt`
|
|
33
33
|
|
|
34
|
-
Available on `ctx.scheduler` in
|
|
35
|
-
|
|
34
|
+
Available on `ctx.scheduler` in a **mutation or an action** — never a `query`,
|
|
35
|
+
which is deterministic and re-runs. Target functions are passed by reference
|
|
36
|
+
from the generated `api` / `internal` proxy (a `"file:fn"` path string also
|
|
37
|
+
works):
|
|
36
38
|
|
|
37
39
|
```ts
|
|
38
|
-
import { mutation, v } from "
|
|
40
|
+
import { mutation, v } from "#lunora/_generated/server.js";
|
|
39
41
|
|
|
40
42
|
import { internal } from "./_generated/api";
|
|
41
43
|
|
|
@@ -47,15 +49,18 @@ export const startTrial = mutation.input({ userId: v.string() }).mutation(async
|
|
|
47
49
|
});
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
- `runAfter(delayMs, fnRef, args
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
- `runAfter(delayMs, fnRef, args?)` — run after a delay (`delayMs` must be a
|
|
53
|
+
non-negative finite number). `runAt(timestampMs, fnRef, args?)` — run at an
|
|
54
|
+
epoch-ms timestamp.
|
|
53
55
|
- Both resolve the job id. Cancel with `ctx.scheduler.cancel(id)`;
|
|
54
56
|
inspect with `ctx.scheduler.get(id)` / `ctx.scheduler.list()`.
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
- Those three arguments are the whole `ctx.scheduler` surface. Per-job
|
|
58
|
+
`RunOptions` — a `retry` policy (`{ maxAttempts, backoff, baseMs, maxMs }`), a
|
|
59
|
+
`shardKey` routing hint, `pool` — live on `@lunora/scheduler`'s own
|
|
60
|
+
`createScheduler(...)` client, which you construct yourself when you need them.
|
|
61
|
+
Jobs scheduled through `ctx.scheduler` take the DO defaults: 5 retries,
|
|
62
|
+
`backoff: "exponential"`, `baseMs: 30_000`. On retry exhaustion the job is
|
|
63
|
+
dead-lettered, never silently dropped.
|
|
59
64
|
- The `SchedulerDO` binding (`SCHEDULER`) is **auto-inferred and reconciled**
|
|
60
65
|
into `wrangler.jsonc` by `@lunora/config` once `@lunora/scheduler` is in use —
|
|
61
66
|
run `lunora codegen` / `lunora doctor` to confirm. Scheduled jobs run with no
|
|
@@ -7,12 +7,19 @@ description: Adds R2-backed file storage to a Lunora app. Use for uploads/downlo
|
|
|
7
7
|
|
|
8
8
|
Wire R2-backed file storage into a Lunora app using the `storage` registry item,
|
|
9
9
|
which is built on `@lunora/storage` (an R2 adapter plus HMAC signed-URL helpers)
|
|
10
|
-
and exposes idiomatic Lunora functions for
|
|
11
|
-
|
|
10
|
+
and exposes idiomatic Lunora functions for browser uploads, gated downloads,
|
|
11
|
+
delete, and list — with no bucket credential in the client.
|
|
12
|
+
|
|
13
|
+
A worker-signed URL points at **your Worker**, not at R2:
|
|
14
|
+
`${STORAGE_PUBLIC_BASE_URL}/<key>?exp&method&bucket&sig`. The `/storage/*` route
|
|
15
|
+
you add in step 4 is what verifies the signature and moves the bytes, for both
|
|
16
|
+
the upload and the download. (The no-Worker-in-the-path variant is
|
|
17
|
+
`@lunora/storage`'s S3 presigned URL, `getPresignedUrl` — it needs S3 credentials
|
|
18
|
+
on the bucket and enforces none of your rules.)
|
|
12
19
|
|
|
13
20
|
## When to Use
|
|
14
21
|
|
|
15
|
-
- Uploading user files (avatars, attachments)
|
|
22
|
+
- Uploading user files (avatars, attachments) into R2 under your own gate.
|
|
16
23
|
- Serving private/gated downloads via short-lived signed URLs.
|
|
17
24
|
- Listing or deleting a caller's stored objects.
|
|
18
25
|
|
|
@@ -27,7 +34,8 @@ downloads, delete, and list — so the bytes never proxy through your Worker.
|
|
|
27
34
|
1. Add the `storage` item.
|
|
28
35
|
2. Configure the `UPLOADS` R2 bucket binding and the signing secret.
|
|
29
36
|
3. Regenerate types with `lunora codegen`.
|
|
30
|
-
4.
|
|
37
|
+
4. Add the `/storage/*` route to the Worker — it verifies signatures and serves
|
|
38
|
+
both the signed `PUT` and the signed `GET`.
|
|
31
39
|
5. Upload/download from the client.
|
|
32
40
|
|
|
33
41
|
## Step 1: Add the item
|
|
@@ -51,11 +59,16 @@ This:
|
|
|
51
59
|
|
|
52
60
|
## Step 2: Configure the binding + secrets
|
|
53
61
|
|
|
54
|
-
| Name | Where | Notes
|
|
55
|
-
| ------------------------- | ------------------------------------ |
|
|
56
|
-
| `UPLOADS` | `wrangler.jsonc` → `r2_buckets[]` | The R2 bucket binding. Point `bucket_name` at a real bucket.
|
|
57
|
-
| `STORAGE_SIGNING_SECRET` | secret (`.dev.vars` / `secret put`) | HMAC secret for signed URLs. Min 32 chars
|
|
58
|
-
| `STORAGE_PUBLIC_BASE_URL` | var (`.dev.vars` / `wrangler.jsonc`) |
|
|
62
|
+
| Name | Where | Notes |
|
|
63
|
+
| ------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
64
|
+
| `UPLOADS` | `wrangler.jsonc` → `r2_buckets[]` | The R2 bucket binding. Point `bucket_name` at a real bucket. |
|
|
65
|
+
| `STORAGE_SIGNING_SECRET` | secret (`.dev.vars` / `secret put`) | HMAC secret for signed URLs. Min 32 chars, enforced — a shorter one throws on the first call. Never share across tenants. |
|
|
66
|
+
| `STORAGE_PUBLIC_BASE_URL` | var (`.dev.vars` / `wrangler.jsonc`) | **Bare origin** running the `/storage/*` route (scaffolded `http://localhost:8787`). A base carrying a path is rejected by the signer. |
|
|
67
|
+
|
|
68
|
+
`STORAGE_PUBLIC_BASE_URL` must be `https://` anywhere but local dev. A signed URL
|
|
69
|
+
_is_ a bearer credential and the object bytes stream through it, so a plaintext
|
|
70
|
+
origin hands both to anyone on the path. Only `http://localhost` /
|
|
71
|
+
`http://127.0.0.1` belong in `.dev.vars`.
|
|
59
72
|
|
|
60
73
|
Generate a real signing secret with `openssl rand -base64 32` and write it with
|
|
61
74
|
`wrangler secret put STORAGE_SIGNING_SECRET` for production.
|
|
@@ -70,20 +83,87 @@ The functions surface in the generated `api` as `api.storage.generateUploadUrl`,
|
|
|
70
83
|
`api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
|
|
71
84
|
`api.storage.listObjects`.
|
|
72
85
|
|
|
73
|
-
## Step 4:
|
|
86
|
+
## Step 4: Add the `/storage/*` route to the Worker
|
|
87
|
+
|
|
88
|
+
**Required, not optional.** Without it a minted URL hits the Lunora catch-all and
|
|
89
|
+
every upload and download 404s — and it is the only thing checking the signature,
|
|
90
|
+
so skipping the check lets anyone read any key.
|
|
91
|
+
|
|
92
|
+
`@lunora/server`'s `serveStorageObject(ctx, key, request, authorize)` handles
|
|
93
|
+
the download half (`Range`/206, `ETag`, `nosniff`, and
|
|
94
|
+
`content-disposition: attachment` for anything outside a small inline-safe set —
|
|
95
|
+
raster images plus `audio/mpeg`, `audio/ogg`, `audio/wav`, `video/mp4`,
|
|
96
|
+
`video/webm`, with `image/svg+xml` deliberately excluded). It verifies nothing on
|
|
97
|
+
its own — its required `authorize` gate is where `verifySignedUrl` goes — and it
|
|
98
|
+
does not handle the upload. Reach for it from an `httpAction`, where `ctx.storage`
|
|
99
|
+
is in scope, whenever you want `Range` seeking or conditional requests.
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
The route below is the standalone version — a plain worker `fetch` with only the
|
|
102
|
+
R2 binding to hand, so it serves whole objects and skips `Range`/`ETag`. Both
|
|
103
|
+
verbs, by hand:
|
|
78
104
|
|
|
79
105
|
```ts
|
|
106
|
+
import { isSafeHeaderValue } from "@lunora/server";
|
|
80
107
|
import { verifySignedUrl } from "@lunora/storage";
|
|
81
108
|
|
|
109
|
+
/** Cap what a single signed PUT may store. */
|
|
110
|
+
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Origins allowed to upload cross-origin. Leave it empty when
|
|
114
|
+
* `STORAGE_PUBLIC_BASE_URL` is your app's own origin — then `cors` is inert and
|
|
115
|
+
* no browser ever preflights these routes.
|
|
116
|
+
*/
|
|
117
|
+
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Types safe to render in the browser. Everything else downloads — an uploader
|
|
121
|
+
* who pinned `text/html` or `image/svg+xml` must never get a same-origin script.
|
|
122
|
+
* (`serveStorageObject` applies this same list.)
|
|
123
|
+
*/
|
|
124
|
+
const INLINE_SAFE = new Set([
|
|
125
|
+
"audio/mpeg",
|
|
126
|
+
"audio/ogg",
|
|
127
|
+
"audio/wav",
|
|
128
|
+
"image/apng",
|
|
129
|
+
"image/avif",
|
|
130
|
+
"image/gif",
|
|
131
|
+
"image/jpeg",
|
|
132
|
+
"image/png",
|
|
133
|
+
"image/webp",
|
|
134
|
+
"video/mp4",
|
|
135
|
+
"video/webm",
|
|
136
|
+
]);
|
|
137
|
+
|
|
82
138
|
export default {
|
|
83
139
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
84
140
|
const url = new URL(request.url);
|
|
85
141
|
|
|
86
142
|
if (url.pathname.startsWith("/storage/")) {
|
|
143
|
+
const origin = request.headers.get("origin");
|
|
144
|
+
// `vary` rides on EVERY response, allowed origin or not: a shared
|
|
145
|
+
// cache keyed on the URL alone would otherwise replay one origin's
|
|
146
|
+
// `access-control-allow-origin` to another.
|
|
147
|
+
const cors = {
|
|
148
|
+
vary: "origin",
|
|
149
|
+
...(origin !== null && ALLOWED_ORIGINS.has(origin)
|
|
150
|
+
? { "access-control-allow-headers": "content-type", "access-control-allow-methods": "GET, PUT", "access-control-allow-origin": origin }
|
|
151
|
+
: {}),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Before the verb check and the signature check: a preflight carries
|
|
155
|
+
// neither the signed method nor any credentials, so answering it
|
|
156
|
+
// later would 405 every cross-origin upload.
|
|
157
|
+
if (request.method === "OPTIONS") {
|
|
158
|
+
return new Response(null, { headers: cors, status: 204 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The method is signed, so a GET URL cannot be replayed as a PUT —
|
|
162
|
+
// check the verb anyway rather than relying on that alone.
|
|
163
|
+
if (request.method !== (url.searchParams.get("method") ?? "GET")) {
|
|
164
|
+
return new Response("method not allowed", { status: 405 });
|
|
165
|
+
}
|
|
166
|
+
|
|
87
167
|
const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET);
|
|
88
168
|
|
|
89
169
|
if (!result.valid || result.key === undefined) {
|
|
@@ -91,14 +171,64 @@ export default {
|
|
|
91
171
|
return new Response("forbidden", { status: 403 });
|
|
92
172
|
}
|
|
93
173
|
|
|
174
|
+
if (request.method === "PUT") {
|
|
175
|
+
// Store the content type the SIGNATURE pins, never the request's
|
|
176
|
+
// own header: the allowlist ran when the URL was minted, so
|
|
177
|
+
// trusting the header lets a caller mint for `image/png` and PUT
|
|
178
|
+
// `text/html` — stored XSS on this origin.
|
|
179
|
+
if (result.contentType === undefined) {
|
|
180
|
+
return new Response("upload URL carries no content type", { status: 400 });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A declared length is the contract: R2 takes `request.body` as a
|
|
184
|
+
// stream, so there is nothing to measure before the write, and
|
|
185
|
+
// treating an ABSENT header as oversized would 413 every valid
|
|
186
|
+
// streamed upload. Demand it (411) and enforce it (413).
|
|
187
|
+
const declared = request.headers.get("content-length");
|
|
188
|
+
|
|
189
|
+
if (declared === null) {
|
|
190
|
+
return new Response("content-length required", { status: 411 });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const length = Number(declared);
|
|
194
|
+
|
|
195
|
+
if (!Number.isFinite(length) || length > MAX_UPLOAD_BYTES) {
|
|
196
|
+
return new Response("upload too large", { status: 413 });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await env.UPLOADS.put(result.key, request.body, { httpMetadata: { contentType: result.contentType } });
|
|
200
|
+
|
|
201
|
+
// The preflight's answer does not carry over: without CORS
|
|
202
|
+
// headers HERE too the browser passes preflight and then rejects
|
|
203
|
+
// the actual response.
|
|
204
|
+
return new Response(null, { headers: cors, status: 204 });
|
|
205
|
+
}
|
|
206
|
+
|
|
94
207
|
const object = await env.UPLOADS.get(result.key);
|
|
95
208
|
|
|
96
209
|
if (!object) {
|
|
97
210
|
return new Response("not found", { status: 404 });
|
|
98
211
|
}
|
|
99
212
|
|
|
213
|
+
// The stored content type came off an uploader-signed URL, so it is
|
|
214
|
+
// attacker-influenced: a CR/LF/NUL in it either throws inside
|
|
215
|
+
// `Headers` (an unhandled 500) or, on a permissive runtime, splits
|
|
216
|
+
// the response. Reject the value rather than reflect it — this is
|
|
217
|
+
// exactly what `isSafeHeaderValue` does inside `serveStorageObject`.
|
|
218
|
+
const rawContentType = object.httpMetadata?.contentType;
|
|
219
|
+
const contentType = rawContentType !== undefined && isSafeHeaderValue(rawContentType) ? rawContentType : "application/octet-stream";
|
|
220
|
+
|
|
100
221
|
return new Response(object.body, {
|
|
101
|
-
headers: {
|
|
222
|
+
headers: {
|
|
223
|
+
...cors,
|
|
224
|
+
// The URL expires; a cached copy would not. Without this a
|
|
225
|
+
// browser or CDN can keep serving private bytes past `exp`,
|
|
226
|
+
// with `verifySignedUrl` never consulted again.
|
|
227
|
+
"cache-control": "private, no-store",
|
|
228
|
+
...(INLINE_SAFE.has(contentType.split(";")[0]?.trim().toLowerCase() ?? "") ? {} : { "content-disposition": "attachment" }),
|
|
229
|
+
"content-type": contentType,
|
|
230
|
+
"x-content-type-options": "nosniff",
|
|
231
|
+
},
|
|
102
232
|
});
|
|
103
233
|
}
|
|
104
234
|
|
|
@@ -108,6 +238,18 @@ export default {
|
|
|
108
238
|
};
|
|
109
239
|
```
|
|
110
240
|
|
|
241
|
+
**Why the CORS lines are there.** If `STORAGE_PUBLIC_BASE_URL` is not your app's
|
|
242
|
+
own origin, the browser `PUT` below is preflighted (`PUT` is not a simple method,
|
|
243
|
+
and `content-type: image/png` is not a safelisted value). Answering `OPTIONS` is
|
|
244
|
+
only half of it: the browser also reads
|
|
245
|
+
`access-control-allow-origin` off the **real** response, so the 204 and the
|
|
246
|
+
download response carry `...cors` too — a route that answers only the preflight
|
|
247
|
+
passes it and then fails the request it was preflighting.
|
|
248
|
+
|
|
249
|
+
Keep `STORAGE_PUBLIC_BASE_URL` same-origin if you would rather not maintain an
|
|
250
|
+
allowlist; then `ALLOWED_ORIGINS` can be empty and `cors` never adds a header
|
|
251
|
+
beyond `vary: origin`.
|
|
252
|
+
|
|
111
253
|
`verifySignedUrl` checks expiry, then the HMAC. On a host-rewrite / CDN topology
|
|
112
254
|
pass `{ expectedHost }` (the `STORAGE_PUBLIC_BASE_URL` host) so the signature
|
|
113
255
|
canonicalizes against the host it was minted for.
|
|
@@ -121,17 +263,23 @@ const { key, url } = await client.action("storage/generateUploadUrl", {
|
|
|
121
263
|
contentType: file.type,
|
|
122
264
|
});
|
|
123
265
|
|
|
124
|
-
// 2. upload
|
|
266
|
+
// 2. upload it — the URL points at your Worker's `/storage/*` route, which
|
|
267
|
+
// verifies the signature and writes to R2. The content type is pinned into
|
|
268
|
+
// the signature (and carried on the URL as `&ct=`); that signed value is what
|
|
269
|
+
// gets stored, so the request's own `content-type` header is not read and
|
|
270
|
+
// cannot override it.
|
|
125
271
|
await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
|
|
126
272
|
|
|
127
273
|
// 3. later, get a signed GET URL to display it
|
|
128
274
|
const { url: downloadUrl } = await client.action("storage/getDownloadUrl", { key: "avatar.png" });
|
|
129
275
|
```
|
|
130
276
|
|
|
131
|
-
Every key is scoped per-tenant with `scopeKey(
|
|
132
|
-
key)
|
|
133
|
-
|
|
134
|
-
|
|
277
|
+
Every key is scoped per-tenant with `scopeKey(requireOwner(ctx.auth.userId),
|
|
278
|
+
key)` — `requireOwner` returns `storage/<userId>` — so a client-supplied key can
|
|
279
|
+
never address another user's data, and the `storage/` prefix is what lands the
|
|
280
|
+
minted URL on the `/storage/*` route. The functions return the **scoped** key
|
|
281
|
+
(`storage/<userId>/avatar.png`) alongside the URL; persist that, and pass the
|
|
282
|
+
bare key back in — the component re-scopes it.
|
|
135
283
|
|
|
136
284
|
## Common Pitfalls
|
|
137
285
|
|
|
@@ -143,16 +291,25 @@ persist that, and pass the bare key back in — the component re-scopes it.
|
|
|
143
291
|
`bucket_name: "replace-me-uploads"` — rename it to a real R2 bucket. (R2 names
|
|
144
292
|
are lowercase alphanumeric + hyphens, 3–63 chars; wrangler rejects anything
|
|
145
293
|
else on `dev`/`deploy`.)
|
|
146
|
-
3. **Short / shared signing secret.**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
294
|
+
3. **Short / shared signing secret.** ≥32 chars is enforced (the item throws on
|
|
295
|
+
the first call below it). Cross-_bucket_ replay is not a risk here — the
|
|
296
|
+
bucket name is part of the HMAC canonical and rides on the URL as `&bucket=`,
|
|
297
|
+
so a URL minted for one bucket never verifies against another under the same
|
|
298
|
+
secret. Cross-_tenant_ reuse is the real hazard: one secret shared between two
|
|
299
|
+
apps lets either mint URLs the other's route will honour, so keep a distinct
|
|
300
|
+
secret per deployment.
|
|
301
|
+
4. **Base URL with a path.** `STORAGE_PUBLIC_BASE_URL` must be a bare origin. The
|
|
302
|
+
key is verified from the whole URL pathname, so a subpath base would make
|
|
303
|
+
every minted URL fail verification — `buildSignedUrl` rejects it up front.
|
|
304
|
+
5. **Routing the body through a Lunora function.** Uploads and downloads go
|
|
305
|
+
through the thin `/storage/*` route, which streams to and from R2 — don't
|
|
306
|
+
read the file into a `query`/`mutation`/`action` argument or return value.
|
|
150
307
|
|
|
151
308
|
## Checklist
|
|
152
309
|
|
|
153
310
|
- [ ] `lunora registry add storage` run, `pnpm install` done.
|
|
154
311
|
- [ ] `UPLOADS` bucket bound to a real bucket; `STORAGE_SIGNING_SECRET` (≥32
|
|
155
|
-
chars) and `STORAGE_PUBLIC_BASE_URL` set.
|
|
312
|
+
chars) and `STORAGE_PUBLIC_BASE_URL` (a bare origin) set.
|
|
156
313
|
- [ ] `lunora codegen` run so `api.storage.*` is generated.
|
|
157
|
-
- [ ]
|
|
314
|
+
- [ ] `/storage/*` route added, verifying signed URLs on both `PUT` and `GET`.
|
|
158
315
|
- [ ] Verified a client upload → signed download round-trip.
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import{V as R}from"../packem_shared/cli-CqNvbTR1.mjs";import{d as T}from"../packem_shared/command-DwC11Cwe.mjs";import{existsSync as u,readFileSync as v,mkdirSync as D,realpathSync as I,statSync as J,writeFileSync as W,chmodSync as z,renameSync as G,unlinkSync as H}from"node:fs";import{homedir as b}from"node:os";import{findWranglerFile as V}from"@lunora/config/cloudflare";import{join as s,dirname as K,relative as q}from"@visulima/path";import{stringify as B}from"smol-toml";import{parse as X,modify as Z,applyEdits as Q}from"jsonc-parser";import{detectPackageManager as Y,execArgsFor as ee,readLiveDevServerState as y}from"@lunora/config";import{resolveAdminToken as re}from"@lunora/config/studio-host";import{connectLocalStdio as te}from"@lunora/mcp";const f=e=>({args:[...e.args],command:e.command,...e.env===void 0?{}:{env:e.env}}),h=e=>e.transport==="http"?{type:"http",url:e.url}:f(e),ne=e=>e.transport==="http"?{httpUrl:e.url}:f(e),oe=e=>e.transport==="http"?{serverUrl:e.url}:f(e),ae=e=>({args:["-y","mcp-remote",e],command:"npx"}),le=e=>e.transport==="http"?ae(e.url):f(e),ce=e=>e.transport==="http"?{url:e.url}:f(e),se=e=>e.transport==="http"?{enabled:!0,type:"remote",url:e.url}:{command:[e.command,...e.args],enabled:!0,type:"local",...e.env===void 0?{}:{environment:e.env}},ie=e=>e.transport==="http"?{disabled:!1,type:"streamableHttp",url:e.url}:{disabled:!1,...f(e)},de=({home:e,platform:r})=>{if(r==="darwin")return{global:s(e,"Library","Application Support","Claude","claude_desktop_config.json")};if(r==="win32"){const t=process.env.APPDATA;return t===void 0||t===""?{}:{global:s(t,"Claude","claude_desktop_config.json")}}return r==="linux"?{global:s(e,".config","Claude","claude_desktop_config.json")}:{}},ue=(e,r)=>B({mcp_servers:{[e]:r.transport==="http"?{url:r.url}:{args:[...r.args],command:r.command,...r.env===void 0?{}:{env:r.env}}}}),p=[{buildEntry:h,paths:({projectRoot:e})=>({project:s(e,".mcp.json")}),format:"json",id:"claude-code",key:"mcpServers",label:"Claude Code"},{buildEntry:h,paths:({home:e,projectRoot:r})=>({global:s(e,".cursor","mcp.json"),project:s(r,".cursor","mcp.json")}),format:"json",id:"cursor",key:"mcpServers",label:"Cursor"},{buildEntry:h,paths:({projectRoot:e})=>({project:s(e,".vscode","mcp.json")}),format:"json",id:"vscode",key:"servers",label:"VS Code (GitHub Copilot)"},{buildEntry:ne,paths:({home:e,projectRoot:r})=>({global:s(e,".gemini","settings.json"),project:s(r,".gemini","settings.json")}),format:"json",id:"gemini",key:"mcpServers",label:"Gemini CLI"},{buildEntry:le,paths:de,format:"json",id:"claude-desktop",key:"mcpServers",label:"Claude Desktop"},{buildEntry:oe,paths:({home:e})=>({global:s(e,".codeium","windsurf","mcp_config.json")}),format:"json",id:"windsurf",key:"mcpServers",label:"Windsurf"},{buildEntry:se,paths:({home:e,projectRoot:r})=>({global:s(e,".config","opencode","opencode.json"),project:s(r,"opencode.json")}),format:"json",id:"opencode",key:"mcp",label:"OpenCode"},{buildEntry:ie,paths:({home:e})=>({global:s(e,".cline","mcp.json")}),format:"json",id:"cline",key:"mcpServers",label:"Cline"},{buildEntry:ce,paths:({home:e})=>({global:s(e,".config","zed","settings.json")}),format:"json",id:"zed",key:"context_servers",label:"Zed"},{paths:({home:e,projectRoot:r})=>({global:s(e,".codex","config.toml"),project:s(r,".codex","config.toml")}),format:"manual",id:"codex",label:"Codex CLI",renderSnippet:ue}],O=p.map(e=>e.id),fe=e=>p.find(r=>r.id===e.toLowerCase()),C=(e,r)=>{let t=e,n;try{t=I(e),n=J(t).mode}catch{}const a=`${t}.lunora-${Math.random().toString(36).slice(2,10)}.tmp`;try{W(a,r,"utf8"),n!==void 0&&z(a,n),G(a,t)}catch(o){try{H(a)}catch{}throw o}},me={formattingOptions:{insertSpaces:!0,tabSize:4}},w=e=>{const r=[],t=X(e,r,{allowTrailingComma:!0});return{errors:r,value:t}},g=e=>typeof e=="object"&&e!==null&&!Array.isArray(e),S=(e,r,t)=>{if(!g(e))return;const n=e[r];return g(n)?n[t]:void 0},x=e=>{if(!u(e.path))return"absent";let r;try{r=v(e.path,"utf8")}catch{return"invalid"}if(r.trim().length===0)return"absent";const{errors:t,value:n}=w(r);return t.length>0||!g(n)?"invalid":S(n,e.key,e.name)===void 0?"absent":"present"},ge=e=>x(e)==="present",M=(e,r,t,n,a)=>{try{const o=Z(e,[t,n],a,me);o.length>0&&C(r,Q(e,o));return}catch(o){return o instanceof Error?o.message:String(o)}},pe=e=>{const{entry:r,force:t=!1,key:n,name:a,path:o}=e,l=u(o)?v(o,"utf8"):"";if(l.trim().length===0)return D(K(o),{recursive:!0}),C(o,`${JSON.stringify({[n]:{[a]:r}},void 0,4)}
|
|
2
|
-
`),{action:"created",path:o};const{errors:c,value:i}=w(l);if(c.length>0)return{action:"invalid",error:`parse error at offset ${String(c[0]?.offset??0)}`,path:o};if(!g(i))return{action:"invalid",error:"the file's root is not a JSON object",path:o};const m=i[n];if(m!==void 0&&!g(m))return{action:"invalid",error:`"${n}" is not an object`,path:o};if(S(i,n,a)!==void 0&&!t)return{action:"skipped",path:o};const k=M(l,o,n,a,r);return k===void 0?{action:"updated",path:o}:{action:"invalid",error:k,path:o}},ve=e=>{const{key:r,name:t,path:n}=e;if(!u(n))return{action:"absent",path:n};let a;try{a=v(n,"utf8")}catch(i){return{action:"invalid",error:i instanceof Error?i.message:String(i),path:n}}const{errors:o,value:l}=w(a);if(o.length>0)return{action:"invalid",error:`parse error at offset ${String(o[0]?.offset??0)}`,path:n};if(S(l,r,t)===void 0)return{action:"absent",path:n};const c=M(a,n,r,t,void 0);return c===void 0?{action:"removed",path:n}:{action:"invalid",error:c,path:n}},he=["global","project"],d=(e,r)=>{const t=q(r,e);return t.length>0&&!t.startsWith("..")?t:e},ye=(e,r)=>{const t=e.paths(r);return he.flatMap(n=>{const a=t[n];return a===void 0?[]:[{client:e,path:a,scope:n}]})},be=(e,r,t,n)=>{const a=e.paths(r),o=t==="global"?"project":"global",l=a[t]??(n?void 0:a[o]);if(l!==void 0)return{client:e,path:l,scope:a[t]===void 0?o:t}},we=e=>p.filter(r=>Object.values(r.paths(e)).some(t=>u(t))),_=(e,r,t,n)=>{if(e.length===0)return r();const a=[];for(const o of e){const l=fe(o);if(l===void 0){t.error(`mcp ${n}: unknown client "${o}". Known clients: ${O.join(", ")}.`);return}a.push(l)}return a},Se=e=>e.client.format==="json",ke="https://lunora.sh/mcp",L="lunora-docs",N="lunora",je=e=>u(s(e,"lunora"))&&V(e)!==void 0,$e=e=>{const{args:r,command:t}=ee(e,"lunora",["mcp","serve"]);return{args:r,command:t,transport:"stdio"}},Ee=e=>({transport:"http",url:e}),Oe=e=>({home:e.home,platform:e.platform,projectRoot:e.cwd}),U=(e,r,t)=>be(e,Oe(t),t.scope??r.preferredScope,t.scope!==void 0||!r.allowScopeFallback),Ce=(e,r,t)=>{const n=U(e,r,t);if(n===void 0){t.logger.warn(`${e.label}: has no ${t.scope??r.preferredScope}-scoped config — skipped "${r.name}".`);return}const{path:a}=n,o=e.buildEntry(r.spec);if(t.print===!0){const i=d(a,t.cwd);return t.force!==!0&&ge({key:e.key,name:r.name,path:a})?(t.logger.info(`${e.label}: "${r.name}" already configured in ${i} — a real install would skip it (re-run with --force to replace).`),{action:"printed",path:a}):(t.logger.info(`${e.label} → ${i}
|
|
3
|
-
${JSON.stringify({[e.key]:{[r.name]:o}},void 0,4)}`),{action:"printed",path:a})}const l=pe({entry:o,force:t.force===!0,key:e.key,name:r.name,path:a}),c=d(a,t.cwd);return l.action==="invalid"?t.logger.error(`${e.label}: ${c} is not valid JSON (${l.error??"unknown error"}) — left untouched.`):l.action==="skipped"?t.logger.info(`${e.label}: "${r.name}" already configured in ${c} — re-run with --force to replace it.`):t.logger.success(`${e.label}: ${l.action==="created"?"created":"updated"} ${c} with "${r.name}".`),{action:l.action,path:a}},xe=(e,r,t)=>{const n=U(e,r,t)?.path;if(n===void 0){t.logger.warn(`${e.label}: no known config location on ${t.platform} — skipped.`);return}return t.logger.info(`${e.label}: add this to ${d(n,t.cwd)}
|
|
4
|
-
|
|
5
|
-
${e.renderSnippet(r.name,r.spec)}`),{action:"printed",path:n}},Me=e=>{const r=[];return e.localOnly!==!0&&r.push({allowScopeFallback:!0,name:L,preferredScope:"global",spec:Ee(e.docsUrl??ke)}),e.docsOnly!==!0&&je(e.cwd)&&r.push({allowScopeFallback:!1,name:N,preferredScope:"project",spec:$e(Y(e.cwd))}),r},_e=e=>{const r=e.home??b(),t=e.platform??process.platform;e.logger.info("Supported MCP clients:");for(const n of p){const a=n.paths({home:r,platform:t,projectRoot:e.cwd}),o=l=>{const c=a[l];return c===void 0?"—":d(c,e.cwd)};e.logger.info(` ${n.id.padEnd(15)} ${n.label.padEnd(24)} project: ${o("project").padEnd(30)} global: ${o("global")}`)}return{code:0,written:[]}},Le=e=>e.docsOnly===!0&&e.localOnly===!0?"`--docs-only` and `--local-only` are mutually exclusive — pass at most one.":"`--local-only` was set but this directory is not a Lunora project (no `lunora/` directory and wrangler config).",Ne=(e,r,t)=>{const n=[];for(const a of e)for(const o of r){const l=a.format==="json"?Ce(a,o,t):xe(a,o,t);l!==void 0&&n.push({action:l.action,client:a.id,path:l.path,server:o.name})}return n},Ue=e=>{const r=e.home??b(),t=e.platform??process.platform,n=_(e.clients,()=>we({home:r,platform:t,projectRoot:e.cwd}),e.logger,"install");if(n===void 0)return{code:1,written:[]};if(n.length===0)return e.logger.error(`mcp install: no MCP client config found — name one explicitly, e.g. \`lunora mcp install claude-code\`. Known clients: ${O.join(", ")}.`),{code:1,written:[]};e.clients.length===0&&e.logger.info(`Detected ${String(n.length)} configured client(s): ${n.map(l=>l.label).join(", ")}.`);const a=Me(e);if(a.length===0)return e.logger.error(`mcp install: nothing to install — ${Le(e)}`),{code:1,written:[]};const o=Ne(n,a,{...e,home:r,platform:t});return o.some(l=>l.action==="created"||l.action==="updated")&&e.logger.info("Restart your editor (or reload its MCP servers) to pick up the change."),{code:o.some(l=>l.action==="invalid")?1:0,written:o}},A=100,F=1e3,j=e=>({content:[{text:JSON.stringify(e,void 0,2),type:"text"}]}),Ae=e=>({content:[{text:e,type:"text"}]}),Fe=e=>{const r=typeof e=="string"?Number(e):e;return typeof r!="number"||!Number.isFinite(r)?A:Math.min(F,Math.max(1,Math.floor(r)))},Pe=(e,r)=>{const t=v(e,"utf8").split(`
|
|
6
|
-
`);return t.at(-1)===""&&t.pop(),t.slice(-r).join(`
|
|
7
|
-
`)},Re=e=>[{definition:{description:"Report whether a `lunora dev` server is running for this project, and its URL, studio URL, mode and uptime. Call this first when a query or mutation tool fails to connect.",inputSchema:{properties:{},type:"object"},name:"lunora_dev_status"},handle:async()=>{const r=y(e);return j(r===void 0?{hint:"Start it with `lunora dev` in the project directory.",running:!1}:{background:r.background===!0,logFile:r.logFile,mode:r.mode,pid:r.pid,running:!0,startedAt:r.startedAt,studioUrl:r.studioUrl,url:r.url})}},{definition:{description:"Return the tail of the running dev server's captured log — worker output, request errors, and stack traces. Only available when the server was started in the background (`lunora dev --background`); a foreground server prints to its own terminal.",inputSchema:{properties:{lines:{description:`How many trailing lines to return (default ${String(A)}, max ${String(F)})`,type:"number"}},type:"object"},name:"lunora_dev_logs"},handle:async r=>{const t=y(e);if(t===void 0)return{content:[{text:"no dev server is running — start one with `lunora dev`",type:"text"}],isError:!0};if(t.logFile===void 0||!u(t.logFile))return{content:[{text:"this dev server is not capturing its output to a file (it runs in the foreground) — restart it with `lunora dev --background` to make its logs readable here",type:"text"}],isError:!0};const n=Fe(r.lines),a=Pe(t.logFile,n);return Ae(a.length===0?"(the dev server log is empty)":a)}}],Te=new Set(["127.0.0.1","::1","localhost"]),P=e=>{try{const{hostname:r}=new URL(e);return Te.has(r.startsWith("[")?r.slice(1,-1):r)}catch{return!1}},$=e=>{const r=e.url??y(e.cwd)?.url;if(r===void 0||r.length===0)return;if(e.token!==void 0&&e.token.length>0)return{token:e.token,url:r};if(e.url!==void 0&&!P(e.url))return{url:r};const t=re(e.cwd);return t===void 0||t.length===0?{url:r}:{token:t,url:r}},De=async e=>{await new Promise(r=>{const t=()=>{r()};e.onclose=t,process.stdin.once("close",t),process.stdin.once("end",t)})},Ie=(e,r)=>{if(e===void 0)return"lunora mcp serve: ready (documentation tools). No dev server running yet — start `lunora dev` and the deployment tools start working, no restart needed.\n";if(e.token!==void 0)return`lunora mcp serve: ready — deployment at ${e.url}
|
|
8
|
-
`;const n=r.url!==void 0&&!P(r.url)?"the project's admin token was NOT sent to this non-local --url; pass --token to authenticate deliberately":"no LUNORA_ADMIN_TOKEN in the environment or .dev.vars; the deployment tools will be refused until one is set";return`lunora mcp serve: ready — deployment at ${e.url} (${n})
|
|
9
|
-
`},Je=async(e,r=te)=>{const t=e.writeError??(o=>{process.stderr.write(o)});let n;try{n=await r({allowWrites:e.allowWrites===!0,deployment:()=>$(e),docs:e.noDocs===!0?!1:{...e.docsUrl===void 0?{}:{baseUrl:e.docsUrl}},extraTools:Re(e.cwd),version:e.version})}catch(o){const l=o instanceof Error?o.message:String(o);return t(`lunora mcp serve: failed to start — ${l}
|
|
10
|
-
`),{code:1,deployment:void 0}}const a=$(e);return t(Ie(a,e)),await De(n),{code:0,deployment:a}},We=e=>{const r=[];return e.localOnly!==!0&&r.push(L),e.docsOnly!==!0&&r.push(N),r},ze=(e,r,t,n)=>{const a=ve({key:e.key,name:r,path:t}),o=d(t,n.cwd);return a.action==="removed"?n.logger.success(`${e.label}: removed "${r}" from ${o}.`):a.action==="invalid"&&n.logger.error(`${e.label}: ${o} is not valid JSON (${a.error??"unknown error"}) — left untouched.`),{action:a.action,path:t}},Ge=(e,r,t,n)=>{const a=x({key:e.key,name:r,path:t}),o=d(t,n.cwd);return a==="invalid"?(n.logger.error(`${e.label}: ${o} is not valid JSON — a real run would report it and exit non-zero.`),{action:"invalid",path:t}):a==="absent"?{action:"absent",path:t}:(n.logger.info(`${e.label}: would remove "${r}" from ${o}.`),{action:"removed",path:t})},He=(e,r,t)=>e.flatMap(n=>ye(n,r)).filter(Se).filter(n=>t===void 0||n.scope===t),Ve=e=>{const r=e.home??b(),t=e.platform??process.platform,n=_(e.clients,()=>p,e.logger,"uninstall");if(n===void 0)return{code:1,removed:[]};const a=We(e);if(a.length===0)return e.logger.error("mcp uninstall: nothing to remove — `--docs-only` and `--local-only` are mutually exclusive, pass at most one."),{code:1,removed:[]};const o=[],l={home:r,platform:t,projectRoot:e.cwd};for(const c of He(n,l,e.scope))for(const i of a){const m=e.print===!0?Ge(c.client,i,c.path,e):ze(c.client,i,c.path,e);m.action!=="absent"&&o.push({...m,client:c.client.id,server:i})}return o.length===0?e.logger.info("Nothing to remove — no Lunora MCP servers were configured in the clients checked."):e.print===!0&&e.logger.info("Nothing was changed — re-run without --print to remove these."),{code:o.some(c=>c.action==="invalid")?1:0,removed:o}},E=e=>e.global===!0?"global":e.project===!0?"project":void 0,Ke=async(e,r)=>{const{code:t}=await Je({allowWrites:r.allowWrites===!0,cwd:e,noDocs:r.docs===!1,version:R,...r.docsUrl===void 0?{}:{docsUrl:r.docsUrl},...r.token===void 0?{}:{token:r.token},...r.url===void 0?{}:{url:r.url}});return{code:t}},ar=T(async({argument:e,cwd:r,logger:t,options:n})=>{const[a,...o]=e;if(a==="serve")return Ke(r,n);if(a==="uninstall"){const l=E(n),{code:c}=Ve({clients:o,cwd:r,docsOnly:n.docsOnly===!0,localOnly:n.localOnly===!0,logger:t,print:n.print===!0,...l===void 0?{}:{scope:l}});return{code:c}}if(a==="install"){if(n.list===!0)return _e({cwd:r,logger:t});const l=E(n),{code:c}=Ue({clients:o,cwd:r,docsOnly:n.docsOnly===!0,force:n.force===!0,localOnly:n.localOnly===!0,logger:t,print:n.print===!0,...l===void 0?{}:{scope:l},...n.docsUrl===void 0?{}:{docsUrl:n.docsUrl}});return{code:c}}return t.error("mcp: unknown subcommand. Usage: lunora mcp <install|uninstall|serve>"),{code:1}});export{ar as execute};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{existsSync as a,readFileSync as o,readdirSync as p,statSync as u}from"node:fs";import{join as r}from"@visulima/path";import m from"./parseManifest-CwPTKdtS.mjs";const l=/[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/gu,d=e=>e.replaceAll(l,""),n=e=>e===void 0?void 0:d(e),c=e=>p(e).filter(i=>{const s=r(e,i);return u(s).isDirectory()&&a(r(s,"registry.json"))}),A=e=>{const i=r(e,"index.json");if(a(i)){const s=JSON.parse(o(i,"utf8"));if(Array.isArray(s.items))return s.items.filter(t=>typeof t=="object"&&t!==null&&typeof t.name=="string").map(t=>({description:n(t.description),name:n(t.name)??t.name}))}return c(e).map(s=>{const t=JSON.parse(o(r(e,s,"registry.json"),"utf8"));return{description:n(t.description),name:s}})},g=e=>({items:c(e).map(s=>{const t=m(JSON.parse(o(r(e,s,"registry.json"),"utf8")),s);return{...t.description===void 0?{}:{description:t.description},name:t.name,...t.title===void 0?{}:{title:t.title}}}).toSorted((s,t)=>s.name.localeCompare(t.name))});export{g as b,A as c,c as l,d as s};
|