@gmickel/gno 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,7 +117,7 @@ gno daemon --detach # headless indexing + resident MCP gateway
117
117
 
118
118
  <!-- public-truth:current-version -->
119
119
 
120
- > Current release: **v2.0.0**. See [CHANGELOG.md](./CHANGELOG.md).
120
+ > Current source version: **v2.1.0**. See [CHANGELOG.md](./CHANGELOG.md).
121
121
 
122
122
  <!-- /public-truth -->
123
123
 
@@ -852,7 +852,12 @@ Or use the Web UI:
852
852
  - **Collections page** → collection menu → **Export for gno.sh**
853
853
  - **Document view** → **Export for gno.sh**
854
854
 
855
- Upload the artifact at [gno.sh/studio](https://gno.sh/studio) and pick a visibility mode:
855
+ Both local dialogs require an explicit access choice. Upload the artifact at
856
+ [gno.sh/studio](https://gno.sh/studio), review its access and audience, then press
857
+ Publish. CLI/API exports keep their public default; pass visibility explicitly
858
+ for restricted content. Hosted plan availability is checked in Studio.
859
+
860
+ Choose the audience:
856
861
 
857
862
  | Mode | Use When |
858
863
  | :-------------- | :------------------------------------------------------------- |
@@ -875,13 +880,20 @@ and image fields accept only uncredentialed public HTTP(S) targets.
875
880
  Resolved local PNG, JPEG, GIF, WebP, and AVIF references are bundled,
876
881
  content-addressed, and deduplicated; external public HTTPS images remain
877
882
  external. The exact serialized artifact is capped at 100 MiB. Public images
878
- use immutable snapshot/generation URLs, secret-link images are authorized on
879
- every request, and encrypted image bytes exist only inside ciphertext before
883
+ use generation routes that check current access without caching, secret-link
884
+ images are authorized on every request, and encrypted image bytes exist only inside ciphertext before
880
885
  the browser creates scoped Blob URLs. Invite-only bundled-image delivery is
881
886
  currently fail-closed; use an asset-free invite, secret link, or encrypted
882
887
  share when local images are required.
883
888
 
884
- Republishing a public, secret-link, or invite-only artifact updates the same URL. Encrypted shares should be replaced from a fresh local export so the server never needs your plaintext.
889
+ Explicitly select an existing publication to update it, or publish a separate
890
+ copy. Content-only updates preserve the active URL; access changes invalidate
891
+ old routes or tokens. Changing into or out of encryption requires a fresh
892
+ local export. Unpublish stops hosted access but retains source and history.
893
+ Delete permanently stops access before durable background cleanup; failed
894
+ cleanup stays denied and can be retried. Neither operation deletes local
895
+ files or independently published copies, and downloads cannot be recalled.
896
+ See [Publishing](docs/PUBLISHING.md) for the full lifecycle and retention limits.
885
897
 
886
898
  Encrypted source-backed publish on `gno.sh` is intentionally disabled. For encrypted shares, use:
887
899
 
Binary file
@@ -0,0 +1 @@
1
+ 8dd833b01ebc0a0cb5747de65a9cd60b06cafcf645c2a74d697c918fe7c642d7 gno-browser-clipper-v2.1.0.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "2.0.0"
24
+ "version": "2.1.0"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
package/spec/cli.md CHANGED
@@ -2297,6 +2297,14 @@ gno publish export <target> \
2297
2297
  [--json]
2298
2298
  ```
2299
2299
 
2300
+ The CLI and `POST /api/publish/export` MUST preserve the existing `public`
2301
+ default when visibility is omitted. Local Web UI export dialogs MUST instead
2302
+ require an explicit mode. Both successful export results MUST report the
2303
+ selected mode in `artifact.spaces[].visibility`. Invalid modes and missing required encryption input
2304
+ MUST fail without an artifact. Encrypted CLI export requires `--passphrase`;
2305
+ the local API requires `encryptionPassphrase`. Neither sends that input to
2306
+ gno.sh. Export is a local operation, not hosted activation or deletion.
2307
+
2300
2308
  Public V1 spaces MUST carry a `manifest` conforming to
2301
2309
  [`publish-artifact.schema.json`](./output-schemas/publish-artifact.schema.json).
2302
2310
  The manifest contains schema version `1.0`, a deterministic projection
@@ -0,0 +1,266 @@
1
+ import { Loader2Icon } from "lucide-react";
2
+ import { useRef, useState } from "react";
3
+
4
+ import type { PublishVisibility } from "../../../publish/artifact";
5
+
6
+ import { apiFetch } from "../hooks/use-api";
7
+ import {
8
+ buildPublishExportRequest,
9
+ downloadPublishArtifactFile,
10
+ PUBLISH_ACCESS_OPTIONS,
11
+ type PublishExportResponse,
12
+ } from "../lib/publish-export";
13
+ import { Button } from "./ui/button";
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from "./ui/dialog";
22
+ import { Input } from "./ui/input";
23
+
24
+ interface PublishExportDialogProps {
25
+ onClose: () => void;
26
+ target: string;
27
+ title: string;
28
+ }
29
+
30
+ /** Mount a fresh dialog for each export so access and secrets never carry over. */
31
+ export function PublishExportDialog({
32
+ onClose,
33
+ target,
34
+ title,
35
+ }: PublishExportDialogProps) {
36
+ const [visibility, setVisibility] = useState<PublishVisibility | null>(null);
37
+ const [passphrase, setPassphrase] = useState("");
38
+ const [passphraseConfirmation, setPassphraseConfirmation] = useState("");
39
+ const [audienceConfirmed, setAudienceConfirmed] = useState(false);
40
+ const [error, setError] = useState<string | null>(null);
41
+ const [busy, setBusy] = useState(false);
42
+ const exporting = useRef(false);
43
+ const [returnFocus] = useState(() => {
44
+ const active = document.activeElement;
45
+ const menuTriggerId = active
46
+ ?.closest('[role="menu"]')
47
+ ?.getAttribute("aria-labelledby");
48
+ const trigger = menuTriggerId
49
+ ? document.getElementById(menuTriggerId)
50
+ : active;
51
+ return trigger instanceof HTMLElement ? trigger : null;
52
+ });
53
+ const selected = PUBLISH_ACCESS_OPTIONS.find(
54
+ ({ value }) => value === visibility
55
+ );
56
+ const encryptionReady =
57
+ visibility !== "encrypted" ||
58
+ (passphrase.trim().length > 0 && passphrase === passphraseConfirmation);
59
+
60
+ const handleExport = async () => {
61
+ if (exporting.current) return;
62
+ setError(null);
63
+ try {
64
+ const body = buildPublishExportRequest({
65
+ target,
66
+ visibility,
67
+ passphrase,
68
+ passphraseConfirmation,
69
+ audienceConfirmed,
70
+ });
71
+ exporting.current = true;
72
+ setBusy(true);
73
+ const result = await apiFetch<PublishExportResponse>(
74
+ "/api/publish/export",
75
+ {
76
+ method: "POST",
77
+ body: JSON.stringify(body),
78
+ }
79
+ );
80
+ if (result.error || !result.data) {
81
+ setError(
82
+ result.error ?? "The export did not return an artifact. Try again."
83
+ );
84
+ return;
85
+ }
86
+ // Refuse a mismatched response rather than downloading a broader artifact.
87
+ if (
88
+ result.data.artifact.spaces.length !== 1 ||
89
+ result.data.artifact.spaces[0]?.visibility !== visibility ||
90
+ result.data.artifact.version !== (visibility === "encrypted" ? 2 : 1)
91
+ ) {
92
+ setError(
93
+ "The returned artifact does not match the reviewed access. Nothing was downloaded."
94
+ );
95
+ return;
96
+ }
97
+ downloadPublishArtifactFile(result.data);
98
+ setPassphrase("");
99
+ setPassphraseConfirmation("");
100
+ onClose();
101
+ } catch (cause) {
102
+ setError(
103
+ cause instanceof Error ? cause.message : "Failed to export. Try again."
104
+ );
105
+ } finally {
106
+ exporting.current = false;
107
+ setBusy(false);
108
+ }
109
+ };
110
+
111
+ return (
112
+ <Dialog
113
+ open
114
+ onOpenChange={(open) => {
115
+ if (!open && !exporting.current) onClose();
116
+ }}
117
+ >
118
+ <DialogContent
119
+ className="publish-export-dialog bg-card"
120
+ onCloseAutoFocus={(event) => {
121
+ if (returnFocus?.isConnected) {
122
+ event.preventDefault();
123
+ returnFocus.focus();
124
+ }
125
+ }}
126
+ showCloseButton={!busy}
127
+ >
128
+ <DialogHeader className="publish-export-dialog-header">
129
+ <DialogTitle>Export for gno.sh</DialogTitle>
130
+ <DialogDescription>
131
+ Review who can read “{title}”. This downloads a local file; upload
132
+ and publish it separately in Studio.
133
+ </DialogDescription>
134
+ </DialogHeader>
135
+ <div className="publish-export-dialog-body">
136
+ <fieldset className="space-y-2" disabled={busy}>
137
+ <legend className="mb-2 font-medium text-sm">
138
+ Who can read this?
139
+ </legend>
140
+ {PUBLISH_ACCESS_OPTIONS.map((option) => (
141
+ <label
142
+ className="flex cursor-pointer items-start gap-3 rounded-md border border-border p-3 transition-colors hover:bg-muted/40 has-[:checked]:border-primary has-[:checked]:bg-primary/10 focus-within:ring-2 focus-within:ring-primary/50"
143
+ key={option.value}
144
+ >
145
+ <input
146
+ checked={visibility === option.value}
147
+ className="mt-1 accent-primary"
148
+ name="publish-access"
149
+ onChange={() => {
150
+ setVisibility(option.value);
151
+ setAudienceConfirmed(false);
152
+ setPassphrase("");
153
+ setPassphraseConfirmation("");
154
+ setError(null);
155
+ }}
156
+ type="radio"
157
+ value={option.value}
158
+ />
159
+ <span className="min-w-0 space-y-1">
160
+ <span className="block font-medium text-sm">
161
+ {option.label}
162
+ </span>
163
+ <span className="block text-muted-foreground text-sm">
164
+ {option.audience}
165
+ </span>
166
+ <span className="block font-mono text-muted-foreground text-xs">
167
+ {option.availability}
168
+ </span>
169
+ </span>
170
+ </label>
171
+ ))}
172
+ </fieldset>
173
+ <p className="text-muted-foreground text-xs">
174
+ Local GNO cannot check your hosted account. Studio checks plan
175
+ availability and Egress Policy before publishing. See{" "}
176
+ <a
177
+ className="text-primary underline"
178
+ href="https://gno.sh/pricing"
179
+ rel="noopener noreferrer"
180
+ target="_blank"
181
+ >
182
+ current plans
183
+ </a>
184
+ .
185
+ </p>
186
+ {visibility === "encrypted" && (
187
+ <fieldset className="space-y-3" disabled={busy}>
188
+ <legend className="mb-2 font-medium text-sm">
189
+ Local encryption
190
+ </legend>
191
+ <p className="text-muted-foreground text-sm">
192
+ Your local GNO server encrypts the notes and bundled assets. The
193
+ passphrase is never sent to gno.sh or included in the downloaded
194
+ file. Keep it safe: lost passphrases cannot be recovered.
195
+ Switching into or out of encrypted sharing requires a new local
196
+ export.
197
+ </p>
198
+ <label className="block space-y-1 text-sm">
199
+ <span>Passphrase</span>
200
+ <Input
201
+ autoComplete="new-password"
202
+ onChange={(event) => setPassphrase(event.target.value)}
203
+ type="password"
204
+ value={passphrase}
205
+ />
206
+ </label>
207
+ <label className="block space-y-1 text-sm">
208
+ <span>Confirm passphrase</span>
209
+ <Input
210
+ autoComplete="new-password"
211
+ onChange={(event) =>
212
+ setPassphraseConfirmation(event.target.value)
213
+ }
214
+ type="password"
215
+ value={passphraseConfirmation}
216
+ />
217
+ </label>
218
+ {passphraseConfirmation &&
219
+ passphrase !== passphraseConfirmation && (
220
+ <p className="text-destructive text-sm" role="status">
221
+ The passphrases do not match.
222
+ </p>
223
+ )}
224
+ </fieldset>
225
+ )}
226
+ {selected && (
227
+ <label className="flex cursor-pointer items-start gap-2 text-sm">
228
+ <input
229
+ checked={audienceConfirmed}
230
+ className="mt-1 accent-primary"
231
+ disabled={busy}
232
+ onChange={(event) => setAudienceConfirmed(event.target.checked)}
233
+ type="checkbox"
234
+ />
235
+ <span>
236
+ I reviewed the {selected.label.toLowerCase()} audience and want
237
+ to export with this access.
238
+ </span>
239
+ </label>
240
+ )}
241
+ {error && (
242
+ <p className="text-destructive text-sm" role="alert">
243
+ {error}
244
+ </p>
245
+ )}
246
+ </div>
247
+ <DialogFooter className="publish-export-dialog-footer">
248
+ <Button disabled={busy} onClick={onClose} variant="outline">
249
+ Cancel
250
+ </Button>
251
+ <Button
252
+ disabled={
253
+ busy || !selected || !audienceConfirmed || !encryptionReady
254
+ }
255
+ onClick={() => {
256
+ void handleExport();
257
+ }}
258
+ >
259
+ {busy && <Loader2Icon className="mr-2 size-4 animate-spin" />}
260
+ {busy ? "Exporting…" : "Download export"}
261
+ </Button>
262
+ </DialogFooter>
263
+ </DialogContent>
264
+ </Dialog>
265
+ );
266
+ }