aloic 0.1.7
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 +88 -0
- package/aloic.mjs +1133 -0
- package/github-action.yml +62 -0
- package/lib/api.mjs +308 -0
- package/lib/config.mjs +97 -0
- package/lib/files.mjs +92 -0
- package/lib/login.mjs +135 -0
- package/lib/selfupdate.mjs +136 -0
- package/lib/ui.mjs +308 -0
- package/lib/update.mjs +101 -0
- package/package.json +34 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Publish to Aloic on every push.
|
|
2
|
+
#
|
|
3
|
+
# Copy this to .github/workflows/deploy.yml in your repository, then add your
|
|
4
|
+
# terminal key as a repository secret called ALOIC_KEY. Make one in Settings on
|
|
5
|
+
# aloic.ai, or run `aloic login` and copy the key it saves.
|
|
6
|
+
#
|
|
7
|
+
# THE BUILD RUNS ON GITHUB'S MACHINE, not on Aloic's. That is the whole design:
|
|
8
|
+
# your Actions minutes do the npm install and the build, and only the finished
|
|
9
|
+
# folder is sent. It costs Aloic no compute, which is why this exists instead
|
|
10
|
+
# of a "connect your repository" button that would need one.
|
|
11
|
+
|
|
12
|
+
name: Deploy to Aloic
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
branches: [main]
|
|
17
|
+
workflow_dispatch:
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
deploy:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: 20
|
|
28
|
+
cache: npm
|
|
29
|
+
|
|
30
|
+
- run: npm ci
|
|
31
|
+
- run: npm run build
|
|
32
|
+
|
|
33
|
+
# THE SAME INSTALLER A LAPTOP USES, rather than `npx aloic`. There is no
|
|
34
|
+
# `aloic` package on npm and there may never be: the releases are hosted
|
|
35
|
+
# at get.aloic.ai, which is the address the tool itself checks for
|
|
36
|
+
# updates. A workflow that reached for the registry would fail on its
|
|
37
|
+
# first run with a package-not-found nobody could act on.
|
|
38
|
+
- name: Install the Aloic CLI
|
|
39
|
+
run: |
|
|
40
|
+
curl -fsSL https://get.aloic.ai | sh
|
|
41
|
+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
42
|
+
|
|
43
|
+
# THE COMMIT MESSAGE ARRIVES AS AN ENVIRONMENT VARIABLE, NOT AS TEXT
|
|
44
|
+
# PASTED INTO THE SCRIPT.
|
|
45
|
+
#
|
|
46
|
+
# It used to be written straight into the command as ${{ ... }}, which
|
|
47
|
+
# GitHub substitutes before bash ever sees the line: a commit message
|
|
48
|
+
# containing a quote and a semicolon becomes a second command, running on
|
|
49
|
+
# the runner, in a step that has the deploy key in its environment.
|
|
50
|
+
# Anyone who can land a commit could have read the secret. Through `env`
|
|
51
|
+
# the message is only ever data.
|
|
52
|
+
- name: Publish
|
|
53
|
+
env:
|
|
54
|
+
ALOIC_KEY: ${{ secrets.ALOIC_KEY }}
|
|
55
|
+
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
|
56
|
+
run: |
|
|
57
|
+
# The first line only: a title is a heading, and the body of a commit
|
|
58
|
+
# message belongs in the deploy's description if anywhere.
|
|
59
|
+
TITLE=$(printf '%s' "$COMMIT_MESSAGE" | head -1)
|
|
60
|
+
# Started by hand from the Actions tab, there is no commit to read.
|
|
61
|
+
[ -n "$TITLE" ] || TITLE="Deploy ${GITHUB_SHA:0:7}"
|
|
62
|
+
aloic deploy ./dist --title "$TITLE"
|
package/lib/api.mjs
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/* Everything this tool knows about the outside world.
|
|
2
|
+
*
|
|
3
|
+
* NO DEPENDENCIES, ANYWHERE IN THIS PACKAGE. A deploy tool is the one thing in
|
|
4
|
+
* a pipeline that has to work on a machine nobody has looked at, on whatever
|
|
5
|
+
* Node happens to be installed, months after it was written. Every dependency
|
|
6
|
+
* is a chance for that to stop being true, and everything here is one fetch
|
|
7
|
+
* and one hash, both of which Node has had built in since 18. */
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
/* The same public configuration the web app ships. None of it is secret: it
|
|
12
|
+
names the project and nothing more, and every path below is still gated by
|
|
13
|
+
the database rules on whoever is signed in. */
|
|
14
|
+
export const API = process.env.ALOIC_API || "https://api.aloic.ai";
|
|
15
|
+
export const PROJECT = "aloicai";
|
|
16
|
+
export const WEB_KEY = "AIzaSyC0yD5S7ggmIOvaWQMCr5FxJ47aeWY4BrU";
|
|
17
|
+
export const RTDB = "https://aloicai-default-rtdb.firebaseio.com";
|
|
18
|
+
/* A document's RESOURCE NAME, which is what the commit API wants, not a URL.
|
|
19
|
+
Passing the full https:// address is rejected with "lacks projects at index
|
|
20
|
+
0", which is a confusing way of saying the same thing. */
|
|
21
|
+
const DOC = `projects/${PROJECT}/databases/(default)/documents`;
|
|
22
|
+
|
|
23
|
+
async function jsonOrThrow(r, what) {
|
|
24
|
+
const text = await r.text();
|
|
25
|
+
let body = null;
|
|
26
|
+
try { body = text ? JSON.parse(text) : null; } catch { /* not json */ }
|
|
27
|
+
if (!r.ok) {
|
|
28
|
+
const why = body?.error?.message || body?.error || text.slice(0, 200) || r.statusText;
|
|
29
|
+
throw new Error(`${what}: ${why}`);
|
|
30
|
+
}
|
|
31
|
+
return body;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* A deploy key, exchanged for a session that can publish to one project and
|
|
35
|
+
nothing else. See api/signin/token.js. */
|
|
36
|
+
export async function signIn(key) {
|
|
37
|
+
const r = await fetch(`${API}/api/signin/token`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ deploy: key })
|
|
41
|
+
});
|
|
42
|
+
const got = await jsonOrThrow(r, "Could not use that deploy key");
|
|
43
|
+
|
|
44
|
+
const s = await fetch(
|
|
45
|
+
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${WEB_KEY}`,
|
|
46
|
+
{
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "content-type": "application/json" },
|
|
49
|
+
body: JSON.stringify({ token: got.customToken, returnSecureToken: true })
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
const session = await jsonOrThrow(s, "Could not start a session");
|
|
53
|
+
return { ...got, idToken: session.idToken };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ---------- the file store ---------- */
|
|
57
|
+
|
|
58
|
+
/* Which blobs this project already holds. Shallow, so the answer is a list of
|
|
59
|
+
names rather than every byte under them: this is the request that makes a
|
|
60
|
+
second deploy send only what changed, and asking for it the ordinary way
|
|
61
|
+
would download the entire site to find out its filenames.
|
|
62
|
+
|
|
63
|
+
Failing here means uploading everything, which is correct and merely slower.
|
|
64
|
+
It must never mean failing the deploy. */
|
|
65
|
+
export async function have(uid, siteId, idToken) {
|
|
66
|
+
try {
|
|
67
|
+
const r = await fetch(
|
|
68
|
+
`${RTDB}/blobs/${uid}/${siteId}.json?shallow=true&auth=${encodeURIComponent(idToken)}`
|
|
69
|
+
);
|
|
70
|
+
if (!r.ok) return new Set();
|
|
71
|
+
const body = await r.json();
|
|
72
|
+
return new Set(body && typeof body === "object" ? Object.keys(body) : []);
|
|
73
|
+
} catch {
|
|
74
|
+
return new Set();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function putBlob(uid, siteId, idToken, hash, type, bytes) {
|
|
79
|
+
const r = await fetch(
|
|
80
|
+
`${RTDB}/blobs/${uid}/${siteId}/${hash}.json?auth=${encodeURIComponent(idToken)}`,
|
|
81
|
+
{
|
|
82
|
+
method: "PUT",
|
|
83
|
+
headers: { "content-type": "application/json" },
|
|
84
|
+
body: JSON.stringify({ t: type, b: bytes.toString("base64") })
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
await jsonOrThrow(r, `Could not store a file`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* The map goes down LAST, so a deploy id never resolves to a half written
|
|
91
|
+
site: until this lands there is nothing pointing at any of it. */
|
|
92
|
+
export async function putMap(uid, siteId, idToken, deployId, map) {
|
|
93
|
+
const r = await fetch(
|
|
94
|
+
`${RTDB}/files/${uid}/${siteId}/${deployId}/__map.json?auth=${encodeURIComponent(idToken)}`,
|
|
95
|
+
{
|
|
96
|
+
method: "PUT",
|
|
97
|
+
headers: { "content-type": "application/json" },
|
|
98
|
+
body: JSON.stringify(map)
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
await jsonOrThrow(r, "Could not finish the deploy");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ---------- the record ---------- */
|
|
105
|
+
|
|
106
|
+
/* Firestore's REST shape wants every value tagged with its type. */
|
|
107
|
+
function val(v) {
|
|
108
|
+
if (v === null || v === undefined) return { nullValue: null };
|
|
109
|
+
if (typeof v === "boolean") return { booleanValue: v };
|
|
110
|
+
if (typeof v === "number") {
|
|
111
|
+
return Number.isInteger(v) ? { integerValue: String(v) } : { doubleValue: v };
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(v)) return { arrayValue: { values: v.map(val) } };
|
|
114
|
+
if (typeof v === "object") return { mapValue: { fields: fields(v) } };
|
|
115
|
+
return { stringValue: String(v) };
|
|
116
|
+
}
|
|
117
|
+
const fields = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, val(v)]));
|
|
118
|
+
|
|
119
|
+
/* ONE COMMIT, so a site can never point at a deploy that has no record and a
|
|
120
|
+
record can never be written without the site being pointed at it. The same
|
|
121
|
+
guarantee the browser gets from a batched write. */
|
|
122
|
+
export async function publish(idToken, siteId, deployId, record, live) {
|
|
123
|
+
const writes = [
|
|
124
|
+
{
|
|
125
|
+
update: {
|
|
126
|
+
name: `${DOC}/sites/${siteId}/deployments/${deployId}`,
|
|
127
|
+
fields: fields(record)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
];
|
|
131
|
+
if (live) {
|
|
132
|
+
writes.push({
|
|
133
|
+
update: {
|
|
134
|
+
name: `${DOC}/sites/${siteId}`,
|
|
135
|
+
fields: fields({ liveDeployId: deployId, status: "live", updatedAt: Date.now() })
|
|
136
|
+
},
|
|
137
|
+
updateMask: { fieldPaths: ["liveDeployId", "status", "updatedAt"] }
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const r = await fetch(
|
|
141
|
+
`https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents:commit`,
|
|
142
|
+
{
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
"content-type": "application/json",
|
|
146
|
+
authorization: `Bearer ${idToken}`
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({ writes })
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
await jsonOrThrow(r, "Could not record the deploy");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* REVOKING A KEY FROM THE MACHINE THAT HOLDS IT.
|
|
155
|
+
*
|
|
156
|
+
* The document is named by the hash of the key, and this machine has the key,
|
|
157
|
+
* so it can work out the name without ever having been told it. Signing out
|
|
158
|
+
* without this leaves a working credential on the account for a machine that
|
|
159
|
+
* has forgotten it, which is the kind of thing nobody goes back and tidies.
|
|
160
|
+
*
|
|
161
|
+
* Best effort: a key that cannot be reached is still forgotten locally, and
|
|
162
|
+
* the account page can always revoke it by hand. */
|
|
163
|
+
/* ---------- drafts ----------
|
|
164
|
+
*
|
|
165
|
+
* A draft is a build that was uploaded and deliberately not published, which
|
|
166
|
+
* makes it the one deploy the terminal has to be able to find again later: you
|
|
167
|
+
* made it in one command and you finish with it in another, possibly the next
|
|
168
|
+
* morning. Everything here works on "the newest draft of this project",
|
|
169
|
+
* because that is what somebody means when they say "the draft".
|
|
170
|
+
*
|
|
171
|
+
* Deployment records are world readable by rule, so this is an ordinary query
|
|
172
|
+
* carrying the session for consistency rather than for permission. */
|
|
173
|
+
export async function newestDraft(idToken, siteId) {
|
|
174
|
+
/* NO orderBy, AND THAT IS THE POINT.
|
|
175
|
+
*
|
|
176
|
+
* The obvious query is "where draft is true, newest first, limit one", and
|
|
177
|
+
* Firestore refuses it: a filter on one field ordered by another needs a
|
|
178
|
+
* composite index, which did not exist, so this failed with a 400 every
|
|
179
|
+
* time. The caller caught it and said the project had no draft, which is the
|
|
180
|
+
* worst kind of bug, a lie told confidently about somebody's data.
|
|
181
|
+
*
|
|
182
|
+
* An equality filter on its own needs only the automatic single-field index
|
|
183
|
+
* and always works. A project has a handful of drafts at most, so twenty are
|
|
184
|
+
* fetched and the newest is chosen here, which costs nothing and removes an
|
|
185
|
+
* index from the list of things that have to be deployed for a command to
|
|
186
|
+
* work at all. */
|
|
187
|
+
const r = await fetch(
|
|
188
|
+
`https://firestore.googleapis.com/v1/${DOC}/sites/${siteId}:runQuery`,
|
|
189
|
+
{
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${idToken}` },
|
|
192
|
+
body: JSON.stringify({
|
|
193
|
+
structuredQuery: {
|
|
194
|
+
from: [{ collectionId: "deployments" }],
|
|
195
|
+
where: {
|
|
196
|
+
fieldFilter: {
|
|
197
|
+
field: { fieldPath: "draft" },
|
|
198
|
+
op: "EQUAL",
|
|
199
|
+
value: { booleanValue: true }
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
limit: 20
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
const rows = await jsonOrThrow(r, "Could not look for a draft");
|
|
208
|
+
|
|
209
|
+
const one = doc => {
|
|
210
|
+
const f = doc.fields || {};
|
|
211
|
+
const str = k => f[k]?.stringValue || "";
|
|
212
|
+
const num = k => Number(f[k]?.integerValue ?? f[k]?.doubleValue ?? 0);
|
|
213
|
+
return {
|
|
214
|
+
id: doc.name.split("/").pop(),
|
|
215
|
+
title: str("title"),
|
|
216
|
+
note: str("note"),
|
|
217
|
+
files: num("files"),
|
|
218
|
+
bytes: num("bytes"),
|
|
219
|
+
createdAt: num("createdAt"),
|
|
220
|
+
expiresAt: f.expiresAt?.integerValue ? num("expiresAt") : 0,
|
|
221
|
+
previewKey: str("previewKey"),
|
|
222
|
+
previewUntil: num("previewUntil")
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const all = (rows || [])
|
|
227
|
+
.map(x => x.document)
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.map(one)
|
|
230
|
+
/* A test build is not a draft. It carries an expiry and throws itself away;
|
|
231
|
+
a draft is a thing somebody is keeping. */
|
|
232
|
+
.filter(d => !d.expiresAt)
|
|
233
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
234
|
+
|
|
235
|
+
return all[0] || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* Point the project at a deploy it already holds, and stop calling that deploy
|
|
239
|
+
a draft. Both in one commit, because a project pointed at a build still
|
|
240
|
+
marked "never published" is a record that argues with itself. */
|
|
241
|
+
export async function publishDraft(idToken, siteId, deployId) {
|
|
242
|
+
const r = await fetch(
|
|
243
|
+
`https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents:commit`,
|
|
244
|
+
{
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${idToken}` },
|
|
247
|
+
body: JSON.stringify({
|
|
248
|
+
writes: [
|
|
249
|
+
{
|
|
250
|
+
update: {
|
|
251
|
+
name: `${DOC}/sites/${siteId}/deployments/${deployId}`,
|
|
252
|
+
fields: fields({ draft: false })
|
|
253
|
+
},
|
|
254
|
+
updateMask: { fieldPaths: ["draft"] }
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
update: {
|
|
258
|
+
name: `${DOC}/sites/${siteId}`,
|
|
259
|
+
fields: fields({ liveDeployId: deployId, status: "live", updatedAt: Date.now() })
|
|
260
|
+
},
|
|
261
|
+
updateMask: { fieldPaths: ["liveDeployId", "status", "updatedAt"] }
|
|
262
|
+
}
|
|
263
|
+
]
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
await jsonOrThrow(r, "Could not publish that draft");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* A temporary address for a build that has none. The same two fields the
|
|
271
|
+
dashboard writes, and the same half hour: see previewUrl in
|
|
272
|
+
src/lib/creators.ts for why a preview is its own host. */
|
|
273
|
+
export const PREVIEW_FOR = 30 * 60 * 1000;
|
|
274
|
+
|
|
275
|
+
export async function setPreview(idToken, siteId, deployId, key, until) {
|
|
276
|
+
const r = await fetch(
|
|
277
|
+
`https://firestore.googleapis.com/v1/${DOC}/sites/${siteId}/deployments/${deployId}`
|
|
278
|
+
+ `?updateMask.fieldPaths=previewKey&updateMask.fieldPaths=previewUntil`,
|
|
279
|
+
{
|
|
280
|
+
method: "PATCH",
|
|
281
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${idToken}` },
|
|
282
|
+
body: JSON.stringify({ fields: fields({ previewKey: key, previewUntil: until }) })
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
await jsonOrThrow(r, "Could not make that preview");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function revoke(key, idToken) {
|
|
289
|
+
const hash = createHash("sha256").update(key).digest("hex");
|
|
290
|
+
const r = await fetch(
|
|
291
|
+
`https://firestore.googleapis.com/v1/projects/${PROJECT}`
|
|
292
|
+
+ `/databases/(default)/documents/deployKeys/${hash}`,
|
|
293
|
+
{ method: "DELETE", headers: { authorization: `Bearer ${idToken}` } }
|
|
294
|
+
);
|
|
295
|
+
return r.ok;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* ---------- naming ---------- */
|
|
299
|
+
|
|
300
|
+
/* The first sixteen bytes of the file's SHA-256, which is what names a blob.
|
|
301
|
+
Has to match the web app exactly or the two would never share a byte. */
|
|
302
|
+
export const hashOf = buf =>
|
|
303
|
+
createHash("sha256").update(buf).digest("hex").slice(0, 32);
|
|
304
|
+
|
|
305
|
+
/* A web path as one database key. The database forbids `.`, `#`, `$`, `[`, `]`
|
|
306
|
+
and `/`, and a web path is made almost entirely of the first and the last;
|
|
307
|
+
base64url survives all of them and is reversible. */
|
|
308
|
+
export const keyOf = path => Buffer.from(path, "utf8").toString("base64url");
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* What this machine remembers between runs.
|
|
2
|
+
*
|
|
3
|
+
* TWO FILES, AND THE SPLIT MATTERS.
|
|
4
|
+
*
|
|
5
|
+
* ~/.aloic/config.json the key, which belongs to the person
|
|
6
|
+
* ./.aloic the project, which belongs to the directory
|
|
7
|
+
*
|
|
8
|
+
* A key is per machine: signing in once should mean every project on this
|
|
9
|
+
* computer can be deployed. Which project a folder publishes to is a fact
|
|
10
|
+
* about the folder, so it lives beside it and can be committed, which is what
|
|
11
|
+
* lets a whole team deploy the same repository without each of them choosing
|
|
12
|
+
* from a list every time.
|
|
13
|
+
*
|
|
14
|
+
* THE KEY FILE IS 0600. It is the only secret this tool holds, and a
|
|
15
|
+
* world-readable secret in a home directory is how shared machines leak.
|
|
16
|
+
* Everything here also yields to environment variables, because a build
|
|
17
|
+
* machine has no home directory worth writing to and no browser to sign in
|
|
18
|
+
* with. */
|
|
19
|
+
|
|
20
|
+
import { chmod, mkdir, readFile, writeFile, unlink } from "node:fs/promises";
|
|
21
|
+
import { homedir, hostname } from "node:os";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
const DIR = process.env.ALOIC_HOME || join(homedir(), ".aloic");
|
|
25
|
+
const FILE = join(DIR, "config.json");
|
|
26
|
+
const PROJECT_FILE = ".aloic";
|
|
27
|
+
|
|
28
|
+
async function readJson(path) {
|
|
29
|
+
try { return JSON.parse(await readFile(path, "utf8")); }
|
|
30
|
+
catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function loadConfig() {
|
|
34
|
+
return (await readJson(FILE)) || {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function saveConfig(patch) {
|
|
38
|
+
const now = { ...(await loadConfig()), ...patch };
|
|
39
|
+
await mkdir(DIR, { recursive: true });
|
|
40
|
+
await writeFile(FILE, JSON.stringify(now, null, 2) + "\n", { mode: 0o600 });
|
|
41
|
+
/* Set explicitly as well as on create, because a file that already existed
|
|
42
|
+
keeps whatever mode it had. */
|
|
43
|
+
await chmod(FILE, 0o600).catch(() => {});
|
|
44
|
+
return now;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function clearConfig() {
|
|
48
|
+
await unlink(FILE).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const configPath = () => FILE;
|
|
52
|
+
|
|
53
|
+
/* The key, from wherever it is. The environment wins over the file so a build
|
|
54
|
+
machine and a laptop can run the same command and mean different things.
|
|
55
|
+
|
|
56
|
+
ALOIC_DEPLOY_KEY still answers, because it was the name first and a rename
|
|
57
|
+
that breaks somebody's pipeline is not a rename, it is an outage. */
|
|
58
|
+
export async function findKey() {
|
|
59
|
+
for (const name of ["ALOIC_KEY", "ALOIC_DEPLOY_KEY"]) {
|
|
60
|
+
const env = process.env[name];
|
|
61
|
+
if (env && env.trim()) return { key: env.trim(), from: name };
|
|
62
|
+
}
|
|
63
|
+
const cfg = await loadConfig();
|
|
64
|
+
if (cfg.key) return { key: cfg.key, from: configPath() };
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ---------- the project a folder publishes to ---------- */
|
|
69
|
+
|
|
70
|
+
/* Walked upward from the working directory, the way every tool that keeps a
|
|
71
|
+
dotfile does, so running the command from a subfolder still finds it. */
|
|
72
|
+
export async function findProject(from = process.cwd()) {
|
|
73
|
+
let at = resolve(from);
|
|
74
|
+
for (;;) {
|
|
75
|
+
const got = await readJson(join(at, PROJECT_FILE));
|
|
76
|
+
if (got?.project) return { ...got, file: join(at, PROJECT_FILE) };
|
|
77
|
+
const up = dirname(at);
|
|
78
|
+
if (up === at) return null;
|
|
79
|
+
at = up;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function saveProject(dir, project, slug) {
|
|
84
|
+
const file = join(resolve(dir), PROJECT_FILE);
|
|
85
|
+
await writeFile(
|
|
86
|
+
file,
|
|
87
|
+
JSON.stringify({ project, slug }, null, 2) + "\n"
|
|
88
|
+
);
|
|
89
|
+
return file;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* What to call the key this machine makes, so a list of them in a browser is
|
|
93
|
+
a list of places rather than a list of "Untitled". */
|
|
94
|
+
export function deviceName() {
|
|
95
|
+
const host = (hostname() || "").split(".")[0];
|
|
96
|
+
return (host || "Terminal").slice(0, 60);
|
|
97
|
+
}
|
package/lib/files.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/* Which files go up, and what they are called.
|
|
2
|
+
*
|
|
3
|
+
* The same rules the browser applies to a dropped folder, because a project
|
|
4
|
+
* deployed from a terminal and the same project dropped into the dashboard
|
|
5
|
+
* have to produce the same site. */
|
|
6
|
+
|
|
7
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { join, relative, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
/* Sized for where the files actually go, which today is a database rather
|
|
11
|
+
than a bucket. A file is held as one base64 string, so the real ceiling is
|
|
12
|
+
a third higher than these and the whole thing has to fit in a free tier. */
|
|
13
|
+
export const MAX_FILES = 400;
|
|
14
|
+
export const MAX_BYTES = 25 * 1024 * 1024;
|
|
15
|
+
export const MAX_ONE = 4 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
const TYPES = {
|
|
18
|
+
html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8",
|
|
19
|
+
css: "text/css; charset=utf-8",
|
|
20
|
+
js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8",
|
|
21
|
+
json: "application/json; charset=utf-8", map: "application/json; charset=utf-8",
|
|
22
|
+
txt: "text/plain; charset=utf-8", xml: "application/xml; charset=utf-8",
|
|
23
|
+
svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
|
24
|
+
gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon",
|
|
25
|
+
mp4: "video/mp4", webm: "video/webm", mp3: "audio/mpeg", wav: "audio/wav",
|
|
26
|
+
woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf",
|
|
27
|
+
wasm: "application/wasm", pdf: "application/pdf",
|
|
28
|
+
webmanifest: "application/manifest+json"
|
|
29
|
+
};
|
|
30
|
+
export const typeOf = path =>
|
|
31
|
+
TYPES[path.split(".").pop()?.toLowerCase() || ""] || "application/octet-stream";
|
|
32
|
+
|
|
33
|
+
/* NOT PART OF A BUILT SITE, and left behind without being counted as a
|
|
34
|
+
problem. Dependencies and version control are the two that would otherwise
|
|
35
|
+
blow past every limit above on the first attempt, and reporting them as
|
|
36
|
+
"skipped" rather than refusing the deploy is the difference between a tool
|
|
37
|
+
that works when pointed at a project root and one that lectures you. */
|
|
38
|
+
const SKIP_DIR = new Set([
|
|
39
|
+
"node_modules", ".git", ".svn", ".hg", ".cache", ".next", ".nuxt",
|
|
40
|
+
".output", ".turbo", ".parcel-cache", ".vercel", ".netlify", ".firebase",
|
|
41
|
+
"__pycache__", ".venv", "venv", ".idea", ".vscode", "coverage"
|
|
42
|
+
]);
|
|
43
|
+
const SKIP_FILE = /^(\.DS_Store|Thumbs\.db|npm-debug\.log.*|\.env(\..+)?)$/;
|
|
44
|
+
|
|
45
|
+
export async function walk(root) {
|
|
46
|
+
const files = [];
|
|
47
|
+
const skipped = [];
|
|
48
|
+
|
|
49
|
+
async function into(dir) {
|
|
50
|
+
let entries;
|
|
51
|
+
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
52
|
+
catch { return; }
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
const full = join(dir, e.name);
|
|
55
|
+
if (e.isDirectory()) {
|
|
56
|
+
if (SKIP_DIR.has(e.name)) { skipped.push(rel(full) + "/"); continue; }
|
|
57
|
+
await into(full);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!e.isFile()) continue;
|
|
61
|
+
if (SKIP_FILE.test(e.name)) { skipped.push(rel(full)); continue; }
|
|
62
|
+
const info = await stat(full);
|
|
63
|
+
if (info.size > MAX_ONE) { skipped.push(`${rel(full)} (too large)`); continue; }
|
|
64
|
+
files.push({ path: rel(full), size: info.size, full });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const rel = full => relative(root, full).split(sep).join("/");
|
|
68
|
+
|
|
69
|
+
const info = await stat(root).catch(() => null);
|
|
70
|
+
if (!info) throw new Error(`There is no folder at ${root}`);
|
|
71
|
+
if (info.isFile()) {
|
|
72
|
+
/* A single file is a site too: index.html on its own is a page. */
|
|
73
|
+
return { files: [{ path: root.split(sep).pop(), size: info.size, full: root }], skipped };
|
|
74
|
+
}
|
|
75
|
+
await into(root);
|
|
76
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
77
|
+
return { files, skipped };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const read = f => readFile(f.full);
|
|
81
|
+
|
|
82
|
+
export function tooMuch(files) {
|
|
83
|
+
const bytes = files.reduce((n, f) => n + f.size, 0);
|
|
84
|
+
if (!files.length) return "There are no files to publish in that folder.";
|
|
85
|
+
if (files.length > MAX_FILES) {
|
|
86
|
+
return `That is ${files.length} files and the limit is ${MAX_FILES}. Publish a built folder rather than a project root.`;
|
|
87
|
+
}
|
|
88
|
+
if (bytes > MAX_BYTES) {
|
|
89
|
+
return `That is ${(bytes / 1048576).toFixed(1)}MB and the limit is ${MAX_BYTES / 1048576}MB.`;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|