backpack-viewer 0.7.0 → 0.7.2
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/app/assets/{index-DFW3OKgJ.js → index-DERxFHor.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/extensions/share/backpack-extension.json +20 -0
- package/dist/extensions/share/index-B-LFGJqd.js +4827 -0
- package/dist/extensions/share/src/index.js +199 -0
- package/dist/extensions/share/style.css +151 -0
- package/dist/extensions/share/vite.config.js +17 -0
- package/dist/server-api-routes.js +18 -0
- package/package.json +2 -2
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
const E = "https://app.backpackontology.com", x = `${E}/.well-known/oauth-authorization-server`, _ = new Uint8Array([66, 80, 65, 75]), S = 1;
|
|
2
|
+
let m = null;
|
|
3
|
+
function I(e) {
|
|
4
|
+
e.registerTaskbarIcon({
|
|
5
|
+
label: "Share",
|
|
6
|
+
iconText: "↗",
|
|
7
|
+
position: "top-right",
|
|
8
|
+
onClick: () => N(e)
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
function N(e) {
|
|
12
|
+
if (m && m.isVisible()) {
|
|
13
|
+
m.setVisible(!1);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const t = e.getGraphName();
|
|
17
|
+
if (!t) return;
|
|
18
|
+
const n = document.createElement("div");
|
|
19
|
+
n.className = "share-panel-body", m ? (m.element.replaceChildren(), m.element.appendChild(n), m.setTitle(`Share "${t}"`), m.setVisible(!0), m.bringToFront()) : (n.textContent = "Loading...", m = e.mountPanel(n, {
|
|
20
|
+
title: `Share "${t}"`,
|
|
21
|
+
defaultPosition: { left: window.innerWidth - 420, top: 80 },
|
|
22
|
+
showFullscreenButton: !1,
|
|
23
|
+
onClose: () => {
|
|
24
|
+
m = null;
|
|
25
|
+
}
|
|
26
|
+
})), C(e, n);
|
|
27
|
+
}
|
|
28
|
+
async function C(e, t) {
|
|
29
|
+
const n = await e.settings.get("relay_token");
|
|
30
|
+
t.replaceChildren(), n ? L(e, t, n) : A(e, t);
|
|
31
|
+
}
|
|
32
|
+
function A(e, t) {
|
|
33
|
+
const n = document.createElement("div");
|
|
34
|
+
n.className = "share-upsell", n.innerHTML = `
|
|
35
|
+
<h4>Share this graph with anyone</h4>
|
|
36
|
+
<p>Encrypt your graph and get a shareable link. Recipients open it in their browser — no install needed. Your data stays encrypted on our servers.</p>
|
|
37
|
+
`;
|
|
38
|
+
const a = document.createElement("button");
|
|
39
|
+
a.className = "share-cta-btn", a.textContent = "Sign in to share", a.addEventListener("click", () => T(e, t)), n.appendChild(a);
|
|
40
|
+
const s = document.createElement("button");
|
|
41
|
+
s.className = "share-token-link", s.textContent = "Or paste an API token", s.addEventListener("click", () => P(e, t)), n.appendChild(s);
|
|
42
|
+
const i = document.createElement("p");
|
|
43
|
+
i.className = "share-trust", i.textContent = "Free account. Your graph is encrypted before upload — we can't read it.", n.appendChild(i), t.replaceChildren(n);
|
|
44
|
+
}
|
|
45
|
+
function P(e, t) {
|
|
46
|
+
const n = document.createElement("div");
|
|
47
|
+
n.className = "share-token-input";
|
|
48
|
+
const a = document.createElement("p");
|
|
49
|
+
a.textContent = "Paste your API token from Backpack App settings:", n.appendChild(a);
|
|
50
|
+
const s = document.createElement("input");
|
|
51
|
+
s.type = "password", s.placeholder = "Token", s.className = "share-input", n.appendChild(s);
|
|
52
|
+
const i = document.createElement("div");
|
|
53
|
+
i.className = "share-btn-row";
|
|
54
|
+
const d = document.createElement("button");
|
|
55
|
+
d.className = "share-btn-primary", d.textContent = "Save", d.addEventListener("click", async () => {
|
|
56
|
+
const c = s.value.trim();
|
|
57
|
+
c && (await e.settings.set("relay_token", c), C(e, t));
|
|
58
|
+
}), i.appendChild(d);
|
|
59
|
+
const l = document.createElement("button");
|
|
60
|
+
l.className = "share-btn-secondary", l.textContent = "Back", l.addEventListener("click", () => C(e, t)), i.appendChild(l), n.appendChild(i), t.replaceChildren(n);
|
|
61
|
+
}
|
|
62
|
+
async function T(e, t) {
|
|
63
|
+
try {
|
|
64
|
+
const a = await (await e.fetch(x)).json(), i = await (await e.fetch(a.registration_endpoint, { method: "POST" })).json(), d = B(), l = await $(d), c = window.location.origin + "/oauth/callback", o = crypto.randomUUID(), r = new URL(a.authorization_endpoint);
|
|
65
|
+
r.searchParams.set("client_id", i.client_id), r.searchParams.set("redirect_uri", c), r.searchParams.set("response_type", "code"), r.searchParams.set("code_challenge", l), r.searchParams.set("code_challenge_method", "S256"), r.searchParams.set("state", o), r.searchParams.has("scope") || r.searchParams.set("scope", "openid email profile offline_access"), sessionStorage.setItem("share_oauth_state", o);
|
|
66
|
+
const h = window.open(r.toString(), "backpack-share-auth", "width=500,height=700"), g = async (p) => {
|
|
67
|
+
var f;
|
|
68
|
+
if (((f = p.data) == null ? void 0 : f.type) !== "backpack-oauth-callback") return;
|
|
69
|
+
window.removeEventListener("message", g);
|
|
70
|
+
const { code: u, returnedState: k } = p.data;
|
|
71
|
+
if (k !== o) return;
|
|
72
|
+
const w = await (await e.fetch(a.token_endpoint, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
75
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code: u, redirect_uri: c, client_id: i.client_id, code_verifier: d }).toString()
|
|
76
|
+
})).json();
|
|
77
|
+
await e.settings.set("relay_token", w.access_token), C(e, t);
|
|
78
|
+
};
|
|
79
|
+
if (window.addEventListener("message", g), !h || h.closed) {
|
|
80
|
+
const p = document.createElement("p");
|
|
81
|
+
p.className = "share-error", p.textContent = "Popup blocked. ";
|
|
82
|
+
const u = document.createElement("a");
|
|
83
|
+
u.href = r.toString(), u.target = "_blank", u.textContent = "Click here to sign in", p.appendChild(u), t.appendChild(p);
|
|
84
|
+
}
|
|
85
|
+
} catch (n) {
|
|
86
|
+
const a = document.createElement("p");
|
|
87
|
+
a.className = "share-error", a.textContent = `Auth failed: ${n.message}`, t.appendChild(a);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function L(e, t, n) {
|
|
91
|
+
const a = document.createElement("div");
|
|
92
|
+
a.className = "share-form";
|
|
93
|
+
const s = document.createElement("label");
|
|
94
|
+
s.className = "share-toggle-row";
|
|
95
|
+
const i = document.createElement("input");
|
|
96
|
+
i.type = "checkbox", i.checked = !0, s.appendChild(i);
|
|
97
|
+
const d = document.createElement("span");
|
|
98
|
+
d.textContent = "Encrypt (recommended)", s.appendChild(d), a.appendChild(s);
|
|
99
|
+
const l = document.createElement("div");
|
|
100
|
+
l.className = "share-pass-row";
|
|
101
|
+
const c = document.createElement("input");
|
|
102
|
+
c.type = "password", c.placeholder = "Passphrase (optional)", c.className = "share-input", l.appendChild(c), a.appendChild(l);
|
|
103
|
+
const o = document.createElement("button");
|
|
104
|
+
o.className = "share-btn-primary", o.textContent = "Share", o.addEventListener("click", async () => {
|
|
105
|
+
o.disabled = !0, o.textContent = "Encrypting...";
|
|
106
|
+
try {
|
|
107
|
+
await U(e, t, n, i.checked, c.value.trim());
|
|
108
|
+
} catch (g) {
|
|
109
|
+
o.disabled = !1, o.textContent = "Share";
|
|
110
|
+
const p = document.createElement("p");
|
|
111
|
+
p.className = "share-error", p.textContent = g.message, a.appendChild(p);
|
|
112
|
+
}
|
|
113
|
+
}), a.appendChild(o);
|
|
114
|
+
const r = document.createElement("p");
|
|
115
|
+
r.className = "share-note", r.textContent = "Recipients open the link in their browser. No install needed.", a.appendChild(r);
|
|
116
|
+
const h = document.createElement("button");
|
|
117
|
+
h.className = "share-token-link", h.textContent = "Sign out", h.addEventListener("click", async () => {
|
|
118
|
+
await e.settings.remove("relay_token"), C(e, t);
|
|
119
|
+
}), a.appendChild(h), t.replaceChildren(a);
|
|
120
|
+
}
|
|
121
|
+
async function U(e, t, n, a, s) {
|
|
122
|
+
const i = e.getGraph(), d = e.getGraphName();
|
|
123
|
+
if (!i || !d) throw new Error("No graph loaded");
|
|
124
|
+
const l = new TextEncoder().encode(JSON.stringify(i));
|
|
125
|
+
let c, o, r = "";
|
|
126
|
+
if (a) {
|
|
127
|
+
const y = await import("../index-B-LFGJqd.js"), w = await y.generateX25519Identity(), f = await y.identityToRecipient(w), b = new y.Encrypter();
|
|
128
|
+
b.addRecipient(f), c = await b.encrypt(l), o = "age-v1", r = btoa(w).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
129
|
+
} else
|
|
130
|
+
c = l, o = "plaintext";
|
|
131
|
+
const h = await R(d, c, o), g = {
|
|
132
|
+
"Content-Type": "application/octet-stream",
|
|
133
|
+
Authorization: `Bearer ${n}`
|
|
134
|
+
};
|
|
135
|
+
s && (g["X-Passphrase"] = s);
|
|
136
|
+
const p = await e.fetch(`${E}/v1/share`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: g,
|
|
139
|
+
body: h
|
|
140
|
+
});
|
|
141
|
+
if (!p.ok) {
|
|
142
|
+
const y = await p.text().catch(() => "");
|
|
143
|
+
throw p.status === 401 ? (await e.settings.remove("relay_token"), C(e, t), new Error("Session expired. Please sign in again.")) : new Error(`Upload failed: ${y}`);
|
|
144
|
+
}
|
|
145
|
+
const u = await p.json(), k = r ? `${u.url}#k=${r}` : u.url;
|
|
146
|
+
v(t, k, a, u.expires_at);
|
|
147
|
+
}
|
|
148
|
+
async function R(e, t, n) {
|
|
149
|
+
const a = new ArrayBuffer(t.byteLength);
|
|
150
|
+
new Uint8Array(a).set(t);
|
|
151
|
+
const s = await crypto.subtle.digest("SHA-256", a), i = "sha256:" + Array.from(new Uint8Array(s)).map((h) => h.toString(16).padStart(2, "0")).join(""), d = JSON.stringify({
|
|
152
|
+
format: n,
|
|
153
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
154
|
+
backpack_name: e,
|
|
155
|
+
graph_count: 1,
|
|
156
|
+
checksum: i
|
|
157
|
+
}), l = new TextEncoder().encode(d), c = new ArrayBuffer(4);
|
|
158
|
+
new DataView(c).setUint32(0, l.length, !1);
|
|
159
|
+
const o = new Uint8Array(9 + l.length + t.length);
|
|
160
|
+
let r = 0;
|
|
161
|
+
return o.set(_, r), r += 4, o[r] = S, r += 1, o.set(new Uint8Array(c), r), r += 4, o.set(l, r), r += l.length, o.set(t, r), o;
|
|
162
|
+
}
|
|
163
|
+
function v(e, t, n, a) {
|
|
164
|
+
const s = document.createElement("div");
|
|
165
|
+
s.className = "share-success";
|
|
166
|
+
const i = document.createElement("h4");
|
|
167
|
+
i.textContent = "Shared!", s.appendChild(i);
|
|
168
|
+
const d = document.createElement("div");
|
|
169
|
+
d.className = "share-link-row";
|
|
170
|
+
const l = document.createElement("input");
|
|
171
|
+
l.type = "text", l.readOnly = !0, l.value = t, l.className = "share-link-input", d.appendChild(l);
|
|
172
|
+
const c = document.createElement("button");
|
|
173
|
+
if (c.className = "share-btn-primary", c.textContent = "Copy", c.addEventListener("click", () => {
|
|
174
|
+
navigator.clipboard.writeText(t), c.textContent = "Copied!", setTimeout(() => {
|
|
175
|
+
c.textContent = "Copy";
|
|
176
|
+
}, 2e3);
|
|
177
|
+
}), d.appendChild(c), s.appendChild(d), n) {
|
|
178
|
+
const o = document.createElement("p");
|
|
179
|
+
o.className = "share-note", o.textContent = "The decryption key is in the link. Anyone with the full link can view. The server cannot read your data.", s.appendChild(o);
|
|
180
|
+
}
|
|
181
|
+
if (a) {
|
|
182
|
+
const o = document.createElement("p");
|
|
183
|
+
o.className = "share-note", o.textContent = `Expires: ${new Date(a).toLocaleDateString()}`, s.appendChild(o);
|
|
184
|
+
}
|
|
185
|
+
e.replaceChildren(s);
|
|
186
|
+
}
|
|
187
|
+
function B() {
|
|
188
|
+
const e = new Uint8Array(32);
|
|
189
|
+
return crypto.getRandomValues(e), btoa(String.fromCharCode(...e)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
190
|
+
}
|
|
191
|
+
async function $(e) {
|
|
192
|
+
const t = new TextEncoder().encode(e), n = new ArrayBuffer(t.byteLength);
|
|
193
|
+
new Uint8Array(n).set(t);
|
|
194
|
+
const a = await crypto.subtle.digest("SHA-256", n);
|
|
195
|
+
return btoa(String.fromCharCode(...new Uint8Array(a))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
196
|
+
}
|
|
197
|
+
export {
|
|
198
|
+
I as activate
|
|
199
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
.share-panel-body {
|
|
2
|
+
padding: 16px;
|
|
3
|
+
font-size: 13px;
|
|
4
|
+
color: var(--text);
|
|
5
|
+
min-width: 300px;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.share-upsell h4 {
|
|
9
|
+
margin: 0 0 8px;
|
|
10
|
+
font-size: 15px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.share-upsell p {
|
|
14
|
+
margin: 0 0 12px;
|
|
15
|
+
color: var(--text-muted);
|
|
16
|
+
line-height: 1.5;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.share-cta-btn {
|
|
20
|
+
display: block;
|
|
21
|
+
width: 100%;
|
|
22
|
+
padding: 10px 16px;
|
|
23
|
+
background: var(--accent);
|
|
24
|
+
color: #fff;
|
|
25
|
+
border: none;
|
|
26
|
+
border-radius: 6px;
|
|
27
|
+
font-size: 14px;
|
|
28
|
+
font-weight: 600;
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
margin-bottom: 8px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.share-cta-btn:hover {
|
|
34
|
+
opacity: 0.9;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.share-token-link {
|
|
38
|
+
background: none;
|
|
39
|
+
border: none;
|
|
40
|
+
color: var(--text-muted);
|
|
41
|
+
font-size: 12px;
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
padding: 4px 0;
|
|
44
|
+
text-decoration: underline;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.share-trust {
|
|
48
|
+
font-size: 11px;
|
|
49
|
+
color: var(--text-muted);
|
|
50
|
+
margin-top: 12px;
|
|
51
|
+
line-height: 1.4;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.share-input {
|
|
55
|
+
width: 100%;
|
|
56
|
+
padding: 8px;
|
|
57
|
+
border: 1px solid var(--border);
|
|
58
|
+
border-radius: 4px;
|
|
59
|
+
background: var(--bg-secondary);
|
|
60
|
+
color: var(--text);
|
|
61
|
+
font-size: 13px;
|
|
62
|
+
box-sizing: border-box;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.share-btn-row {
|
|
66
|
+
display: flex;
|
|
67
|
+
gap: 8px;
|
|
68
|
+
margin-top: 8px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.share-btn-primary {
|
|
72
|
+
padding: 8px 16px;
|
|
73
|
+
background: var(--accent);
|
|
74
|
+
color: #fff;
|
|
75
|
+
border: none;
|
|
76
|
+
border-radius: 4px;
|
|
77
|
+
font-size: 13px;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.share-btn-primary:disabled {
|
|
82
|
+
opacity: 0.5;
|
|
83
|
+
cursor: default;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.share-btn-secondary {
|
|
87
|
+
padding: 8px 16px;
|
|
88
|
+
background: var(--bg-secondary);
|
|
89
|
+
color: var(--text);
|
|
90
|
+
border: 1px solid var(--border);
|
|
91
|
+
border-radius: 4px;
|
|
92
|
+
font-size: 13px;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.share-toggle-row {
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
gap: 8px;
|
|
100
|
+
margin-bottom: 12px;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.share-pass-row {
|
|
105
|
+
margin-bottom: 12px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.share-form {
|
|
109
|
+
display: flex;
|
|
110
|
+
flex-direction: column;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.share-note {
|
|
114
|
+
font-size: 11px;
|
|
115
|
+
color: var(--text-muted);
|
|
116
|
+
margin-top: 8px;
|
|
117
|
+
line-height: 1.4;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.share-error {
|
|
121
|
+
color: var(--danger, #e53e3e);
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
margin-top: 8px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.share-success h4 {
|
|
127
|
+
margin: 0 0 12px;
|
|
128
|
+
color: var(--text);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.share-link-row {
|
|
132
|
+
display: flex;
|
|
133
|
+
gap: 8px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.share-link-input {
|
|
137
|
+
flex: 1;
|
|
138
|
+
padding: 8px;
|
|
139
|
+
border: 1px solid var(--border);
|
|
140
|
+
border-radius: 4px;
|
|
141
|
+
background: var(--bg-secondary);
|
|
142
|
+
color: var(--text);
|
|
143
|
+
font-size: 12px;
|
|
144
|
+
font-family: monospace;
|
|
145
|
+
box-sizing: border-box;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.share-token-input p {
|
|
149
|
+
margin: 0 0 8px;
|
|
150
|
+
color: var(--text-muted);
|
|
151
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
build: {
|
|
5
|
+
lib: {
|
|
6
|
+
entry: resolve(__dirname, "src/index.ts"),
|
|
7
|
+
formats: ["es"],
|
|
8
|
+
fileName: () => "src/index.js",
|
|
9
|
+
},
|
|
10
|
+
outDir: resolve(__dirname, "../../dist/extensions/share"),
|
|
11
|
+
emptyOutDir: false,
|
|
12
|
+
rollupOptions: {
|
|
13
|
+
// Don't externalize anything — bundle all deps into one file
|
|
14
|
+
external: [],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -428,6 +428,24 @@ export async function handleApiRequest(req, res, ctx) {
|
|
|
428
428
|
return true;
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
|
+
// --- /oauth/callback (for Share extension OAuth popup) ---
|
|
432
|
+
if (url.startsWith("/oauth/callback") && method === "GET") {
|
|
433
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
434
|
+
res.end(`<!DOCTYPE html><html><body><script>
|
|
435
|
+
var params = new URLSearchParams(window.location.search);
|
|
436
|
+
var code = params.get("code");
|
|
437
|
+
var state = params.get("state");
|
|
438
|
+
if (window.opener && code) {
|
|
439
|
+
window.opener.postMessage({
|
|
440
|
+
type: "backpack-oauth-callback",
|
|
441
|
+
code: code,
|
|
442
|
+
returnedState: state
|
|
443
|
+
}, "*");
|
|
444
|
+
}
|
|
445
|
+
window.close();
|
|
446
|
+
</script></body></html>`);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
431
449
|
return false;
|
|
432
450
|
}
|
|
433
451
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backpack-viewer",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Web-based graph visualizer for backpack-ontology — Canvas 2D, force-directed layout, live reload",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Noah Irzinger",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"dev": "vite",
|
|
17
|
-
"build": "rm -rf dist && tsc && tsc -p tsconfig.extensions.json && cp src/style.css dist/ && node scripts/build-extensions.js && vite build",
|
|
17
|
+
"build": "rm -rf dist && tsc && tsc -p tsconfig.extensions.json && cp src/style.css dist/ && node scripts/build-extensions.js && node scripts/bundle-extensions.js && vite build",
|
|
18
18
|
"preview": "vite preview",
|
|
19
19
|
"serve": "node bin/serve.js",
|
|
20
20
|
"prepare": "npm run build",
|