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
package/aloic.mjs
ADDED
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* aloic — publish a folder to Aloic.
|
|
3
|
+
*
|
|
4
|
+
* aloic login sign this machine in through a browser
|
|
5
|
+
* aloic init choose which project this folder publishes to
|
|
6
|
+
* aloic deploy ./dist publish it
|
|
7
|
+
*
|
|
8
|
+
* THE THREE COMMANDS ARE ONE COMMAND. Running `aloic deploy` with nothing set
|
|
9
|
+
* up does the login and the project choice on the way past, because a tool
|
|
10
|
+
* that stops to tell you to run two other commands first has simply moved the
|
|
11
|
+
* work onto the person. Each step is still available on its own for the cases
|
|
12
|
+
* where somebody wants to do them deliberately.
|
|
13
|
+
*
|
|
14
|
+
* EXIT CODES MATTER HERE more than in most tools: this runs unattended, and a
|
|
15
|
+
* pipeline decides whether a deploy worked by asking this process. Zero means
|
|
16
|
+
* the site is live. Anything else means it is not, and the reason is on stderr
|
|
17
|
+
* in one line that names what to do about it. */
|
|
18
|
+
|
|
19
|
+
import { readFile, writeFile, rm, rmdir, stat } from "node:fs/promises";
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
import { signIn, have, putBlob, putMap, publish, revoke, hashOf, keyOf } from "./lib/api.mjs";
|
|
26
|
+
import { walk, read, typeOf, tooMuch } from "./lib/files.mjs";
|
|
27
|
+
import {
|
|
28
|
+
findKey, findProject, saveProject, loadConfig, saveConfig, clearConfig,
|
|
29
|
+
configPath, deviceName
|
|
30
|
+
} from "./lib/config.mjs";
|
|
31
|
+
import { startLogin, awaitApproval, openBrowser } from "./lib/login.mjs";
|
|
32
|
+
import { checkForUpdate, tellAboutUpdate } from "./lib/update.mjs";
|
|
33
|
+
import { c, out, ok, info, bad, bar, pick, ask, secret, spin, tty, confirm } from "./lib/ui.mjs";
|
|
34
|
+
|
|
35
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const VERSION = JSON.parse(
|
|
37
|
+
await readFile(resolve(here, "package.json"), "utf8")
|
|
38
|
+
).version;
|
|
39
|
+
|
|
40
|
+
const WEB = process.env.ALOIC_WEB || "https://aloic.ai";
|
|
41
|
+
|
|
42
|
+
/* ---------- how this copy of the tool behaves ----------
|
|
43
|
+
*
|
|
44
|
+
* CHOSEN IN THE BROWSER DURING SETUP, kept in the same file as the key, and
|
|
45
|
+
* changeable afterwards with `aloic settings`. Three, and every one of them is
|
|
46
|
+
* honoured somewhere below: a setting that does nothing is worse than no
|
|
47
|
+
* setting, because it teaches people the screen is decoration.
|
|
48
|
+
*
|
|
49
|
+
* The defaults are what the tool did before any of this existed, so an
|
|
50
|
+
* existing install that has never seen the setup screen behaves identically. */
|
|
51
|
+
const DEFAULTS = {
|
|
52
|
+
/* Whether `aloic deploy` points the project at what it just uploaded, or
|
|
53
|
+
keeps it as a draft for somebody to publish deliberately. */
|
|
54
|
+
publish: true,
|
|
55
|
+
/* Whether it asks first. Off, because a deploy tool that stops to ask on
|
|
56
|
+
every run is a deploy tool people wrap in `yes |`. */
|
|
57
|
+
confirm: false,
|
|
58
|
+
/* What to do about a newer version: install it after the command finishes,
|
|
59
|
+
or say nothing until `aloic update` is run. Never automatic without a
|
|
60
|
+
terminal, whatever this says: see lib/selfupdate.mjs. */
|
|
61
|
+
updates: "auto"
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/* The three questions, in the words the setup screen in the browser uses. Both
|
|
65
|
+
places ask the same thing; a tool and its setup page disagreeing about what
|
|
66
|
+
a setting is called is how somebody ends up with two mental models of one
|
|
67
|
+
switch. */
|
|
68
|
+
const SETTINGS = [
|
|
69
|
+
{
|
|
70
|
+
key: "publish",
|
|
71
|
+
q: "When you run aloic deploy",
|
|
72
|
+
on: "Publish it",
|
|
73
|
+
off: "Save it as a draft",
|
|
74
|
+
/* Shown under the answer, because the thing worth knowing about choosing
|
|
75
|
+
to publish is that the other behaviour is still one flag away. */
|
|
76
|
+
note: "You can still run aloic deploy --draft any time to save the deployment as a draft."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: "confirm",
|
|
80
|
+
q: "Before publishing",
|
|
81
|
+
on: "Ask me first",
|
|
82
|
+
off: "Publish immediately"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
key: "updates",
|
|
86
|
+
q: "New versions",
|
|
87
|
+
on: "Update automatically",
|
|
88
|
+
off: "Only notify me",
|
|
89
|
+
/* These two are words rather than true and false, because "false" for an
|
|
90
|
+
update setting reads as "never" and this one means "when you say so". */
|
|
91
|
+
values: ["auto", "ask"]
|
|
92
|
+
}
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
async function settings() {
|
|
96
|
+
const cfg = await loadConfig();
|
|
97
|
+
return { ...DEFAULTS, ...(cfg.settings || {}) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const args = process.argv.slice(2);
|
|
101
|
+
const cmd = (args[0] || "").replace(/^-+/, "") || "";
|
|
102
|
+
const flag = (name, fallback = null) => {
|
|
103
|
+
const i = args.indexOf(`--${name}`);
|
|
104
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : fallback;
|
|
105
|
+
};
|
|
106
|
+
const has = name => args.includes(`--${name}`);
|
|
107
|
+
|
|
108
|
+
const die = (s, code = 1) => { process.stderr.write(`${c.red("✗")} ${s}\n`); process.exit(code); };
|
|
109
|
+
|
|
110
|
+
const HELP = `${c.bold("aloic")} ${c.grey(VERSION)} publish a folder to Aloic
|
|
111
|
+
|
|
112
|
+
${c.bold("Commands")}
|
|
113
|
+
login sign this machine in through your browser
|
|
114
|
+
logout revoke this machine's key and forget it
|
|
115
|
+
settings change how this machine's CLI behaves
|
|
116
|
+
update move to the newest version
|
|
117
|
+
uninstall remove the CLI and everything it wrote
|
|
118
|
+
init choose which project this folder publishes to
|
|
119
|
+
deploy [folder] publish a folder and point the project at it
|
|
120
|
+
deploy draft publish the draft this project is holding
|
|
121
|
+
test draft open the draft at a temporary address
|
|
122
|
+
projects list the projects this machine can publish to
|
|
123
|
+
whoami show who this machine is signed in as
|
|
124
|
+
|
|
125
|
+
${c.bold("Options")}
|
|
126
|
+
--title <text> names the deploy, shown as its heading
|
|
127
|
+
--description <text> what changed, shown under the name
|
|
128
|
+
--project <slug> publish to this project, ignoring .aloic
|
|
129
|
+
--draft upload without pointing the project at it
|
|
130
|
+
--publish publish it, whatever the saved setting says
|
|
131
|
+
--keep-key uninstall without removing the saved key
|
|
132
|
+
--quiet print only the address at the end
|
|
133
|
+
--version print the version and exit
|
|
134
|
+
|
|
135
|
+
${c.grey("Unattended, set ALOIC_KEY and skip login entirely.")}`;
|
|
136
|
+
|
|
137
|
+
/* ---------- signing in ---------- */
|
|
138
|
+
|
|
139
|
+
async function sessionOrLogin({ quiet } = {}) {
|
|
140
|
+
const found = await findKey();
|
|
141
|
+
if (found) return await signIn(found.key);
|
|
142
|
+
|
|
143
|
+
if (!tty()) {
|
|
144
|
+
die("Not signed in. Set ALOIC_KEY, or run `aloic login` on a machine with a browser.");
|
|
145
|
+
}
|
|
146
|
+
if (!quiet) {
|
|
147
|
+
out("");
|
|
148
|
+
info("This machine is not signed in yet.");
|
|
149
|
+
}
|
|
150
|
+
/* This one goes on to publish, so it needs the session and not just the
|
|
151
|
+
key. See the note on `session` in login. */
|
|
152
|
+
return await login({ silent: true, session: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* `session` says whether the caller needs a signed-in session back, or only
|
|
156
|
+
needs the machine set up. Setting up and having a session are not the same
|
|
157
|
+
thing: `aloic login` finishes the moment the key is saved, while `deploy`
|
|
158
|
+
needs to go on and use it. Exchanging the key for a session regardless meant
|
|
159
|
+
a round trip after the success line was already printed, so a bad minute on
|
|
160
|
+
the network produced a green tick followed by a crash. */
|
|
161
|
+
async function login({ silent, bare, session } = {}) {
|
|
162
|
+
/* IS THERE ANYTHING AT THE OTHER END. Signing in means opening a page and
|
|
163
|
+
waiting for somebody to press a button on it, so a web app that is not
|
|
164
|
+
answering produces the worst possible failure: a browser tab showing an
|
|
165
|
+
error, a spinner in the terminal, and ten minutes until the request
|
|
166
|
+
expires. One HEAD request before any of that turns it into a sentence.
|
|
167
|
+
|
|
168
|
+
Not fatal on its own: a machine behind a proxy that blocks HEAD, or with
|
|
169
|
+
no route out at all while the browser has one, should not be stopped by
|
|
170
|
+
our own reachability check. It says what it saw and carries on. */
|
|
171
|
+
const reachable = await fetch(WEB, {
|
|
172
|
+
method: "HEAD",
|
|
173
|
+
redirect: "follow",
|
|
174
|
+
signal: AbortSignal.timeout(4000)
|
|
175
|
+
}).then(r => r.ok, () => false);
|
|
176
|
+
if (!reachable) {
|
|
177
|
+
out("");
|
|
178
|
+
bad(`${WEB} is not answering.`);
|
|
179
|
+
info("Signing in needs the Aloic web app, which approves the request.");
|
|
180
|
+
info("Set ALOIC_WEB if you are running it somewhere else, or use a key:");
|
|
181
|
+
out(` ${c.grey("ALOIC_KEY=alo_... aloic deploy ./dist")}`);
|
|
182
|
+
out("");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ask = await startLogin(WEB);
|
|
186
|
+
|
|
187
|
+
out("");
|
|
188
|
+
out(` ${c.bold("Sign in to Aloic")}`);
|
|
189
|
+
out("");
|
|
190
|
+
out(` ${c.grey("Opening")} ${c.cyan(ask.url)}`);
|
|
191
|
+
out("");
|
|
192
|
+
out(` ${c.grey("Approve the request for")} ${c.bold(deviceName())} ${c.grey("in your browser.")}`);
|
|
193
|
+
out("");
|
|
194
|
+
openBrowser(ask.url);
|
|
195
|
+
|
|
196
|
+
/* THE WAIT CHANGES CHARACTER WHEN THEY PRESS ALLOW, and the line has to say
|
|
197
|
+
so. Until then this is waiting on a decision; after it, the decision is
|
|
198
|
+
made and the browser is asking how the tool should behave, which is a
|
|
199
|
+
different thing to be told to go and do. */
|
|
200
|
+
const s = spin("Waiting for you to approve it");
|
|
201
|
+
let phase = "waiting";
|
|
202
|
+
let got;
|
|
203
|
+
try {
|
|
204
|
+
got = await awaitApproval(ask, st => {
|
|
205
|
+
if (st === "setup" && phase !== "setup") {
|
|
206
|
+
phase = "setup";
|
|
207
|
+
s.set("Setting up Aloic");
|
|
208
|
+
s.say("Continue in your browser to finish setting up.");
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
s.stop();
|
|
212
|
+
} catch (e) {
|
|
213
|
+
s.stop();
|
|
214
|
+
/* CANCELLED IS NOT AN ERROR, it is a decision, and the two deserve
|
|
215
|
+
different words and different exits. Somebody who meant to cancel does
|
|
216
|
+
not need a red cross and a stack of advice; somebody who cancelled by
|
|
217
|
+
accident needs the way back to be one keypress rather than remembering
|
|
218
|
+
the command. */
|
|
219
|
+
/* A CLOSED TAB IS ONE KEYPRESS FROM BEING OPEN AGAIN. Everything needed
|
|
220
|
+
to try is still true: the account, the machine, the intent. A menu here
|
|
221
|
+
would be three choices where there is one obvious one. */
|
|
222
|
+
if (e.closed) return await afterClosed({ silent, bare, session });
|
|
223
|
+
if (e.cancelled) return await afterCancel({ silent, bare, session });
|
|
224
|
+
die(e.message);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* The choices made in the browser arrive with the key and are written down
|
|
228
|
+
beside it, so the first command after this already behaves the way the
|
|
229
|
+
setup screen said it would. Defaults for anything that flow did not
|
|
230
|
+
answer, which is every sign in from before it existed. */
|
|
231
|
+
await saveConfig({
|
|
232
|
+
key: got.key,
|
|
233
|
+
email: got.email || "",
|
|
234
|
+
savedAt: Date.now(),
|
|
235
|
+
settings: { ...DEFAULTS, ...(got.settings || {}) }
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (phase === "setup") {
|
|
239
|
+
ok("Aloic CLI was successfully set up.");
|
|
240
|
+
/* THE COMMAND THAT WORKS FROM WHERE THEY ARE. Somebody who came here
|
|
241
|
+
straight from the installer is standing in a shell whose PATH has not
|
|
242
|
+
caught up, and telling them to run `aloic` is how the installer used to
|
|
243
|
+
send people to "no such file or directory".
|
|
244
|
+
|
|
245
|
+
Not said at all when this WAS `aloic`: the list of commands is printed
|
|
246
|
+
underneath a moment later, so telling them to run the thing they just
|
|
247
|
+
ran to see the thing they are about to see is noise. */
|
|
248
|
+
if (!bare) {
|
|
249
|
+
info(onPath()
|
|
250
|
+
? "Run `aloic` to see what it can do."
|
|
251
|
+
: "Open a new terminal and run `aloic` to see what it can do.");
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
ok(`Signed in as ${c.bold(got.email || got.name || "your account")}`);
|
|
255
|
+
info(`Key saved to ${configPath()}, named ${deviceName()}`);
|
|
256
|
+
}
|
|
257
|
+
if (!silent) out("");
|
|
258
|
+
return session ? await signIn(got.key) : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* Whether the short name resolves in the shell this is running in. Used only
|
|
262
|
+
to choose between two sentences. */
|
|
263
|
+
function onPath() {
|
|
264
|
+
const dirs = String(process.env.PATH || "").split(":");
|
|
265
|
+
return dirs.includes(join(homedir(), ".local", "bin"));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* What happens when the setup tab goes away without an answer.
|
|
269
|
+
*
|
|
270
|
+
* Not the cancel menu: closing a window is rarely a decision about signing in,
|
|
271
|
+
* and offering to paste a key by hand as one of three equal options is a menu
|
|
272
|
+
* about the wrong thing. One line, one key, and the browser opens again. */
|
|
273
|
+
async function afterClosed(opts = {}) {
|
|
274
|
+
out("");
|
|
275
|
+
bad("The setup tab was closed.");
|
|
276
|
+
if (!tty()) process.exit(1);
|
|
277
|
+
out("");
|
|
278
|
+
await ask(` ${c.grey("Press")} ${c.bold("Enter")} ${c.grey("to reopen setup")}`);
|
|
279
|
+
return await login({ ...opts, silent: true });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* What happens after somebody presses Cancel in the browser. */
|
|
283
|
+
async function afterCancel(opts = {}) {
|
|
284
|
+
out("");
|
|
285
|
+
bad("Authentication cancelled.");
|
|
286
|
+
out("");
|
|
287
|
+
if (!tty()) process.exit(1);
|
|
288
|
+
|
|
289
|
+
const choice = await pick(` ${c.grey("What now?")}`, [
|
|
290
|
+
{ k: "retry", t: "Try signing in again" },
|
|
291
|
+
{ k: "paste", t: "Paste a terminal key instead" },
|
|
292
|
+
{ k: "quit", t: "Quit" }
|
|
293
|
+
], x => x.t);
|
|
294
|
+
|
|
295
|
+
if (choice.k === "quit") process.exit(1);
|
|
296
|
+
if (choice.k === "retry") return await login({ ...opts, silent: true });
|
|
297
|
+
|
|
298
|
+
out("");
|
|
299
|
+
info(`Make one in Settings on ${WEB}, then paste it here.`);
|
|
300
|
+
const key = await secret(` ${c.bold("Key")}`);
|
|
301
|
+
if (!/^alo_[A-Za-z0-9_-]{32,64}$/.test(key)) {
|
|
302
|
+
die("That does not look like a terminal key. They start with alo_.");
|
|
303
|
+
}
|
|
304
|
+
const who = await signIn(key).catch(() => die("That key was not accepted."));
|
|
305
|
+
await saveConfig({ key, email: who.email || "", savedAt: Date.now() });
|
|
306
|
+
ok(`Signed in as ${c.bold(who.email || "your account")}`);
|
|
307
|
+
return who;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ---------- choosing a project ---------- */
|
|
311
|
+
|
|
312
|
+
const label = s => {
|
|
313
|
+
const live = s.status === "live" ? c.green("live") : c.grey(s.status || "draft");
|
|
314
|
+
return `${s.name || s.slug} ${c.grey(s.primaryDomain || `${s.slug}.aloic.ai`)} ${live}`;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
async function chooseProject(who, { save = true, dir = process.cwd() } = {}) {
|
|
318
|
+
if (!who.sites?.length) {
|
|
319
|
+
die(`No projects on this account yet. Make one at ${WEB}/dashboard/projects/new`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const named = flag("project");
|
|
323
|
+
if (named) {
|
|
324
|
+
/* THE NAME THIS TOOL ITSELF PRINTS HAS TO WORK. It listed "Test Project",
|
|
325
|
+
asked for a slug, and then refused "Test Project": three behaviours that
|
|
326
|
+
only make sense if you already know the two are different things.
|
|
327
|
+
Matched case insensitively on either, and the error lists what was
|
|
328
|
+
actually on offer rather than leaving somebody to remember a command. */
|
|
329
|
+
const want = named.trim().toLowerCase();
|
|
330
|
+
const hit = who.sites.find(s =>
|
|
331
|
+
s.slug.toLowerCase() === want
|
|
332
|
+
|| s.id === named
|
|
333
|
+
|| (s.name || "").trim().toLowerCase() === want);
|
|
334
|
+
if (!hit) {
|
|
335
|
+
die(`No project called "${named}" on this account. Yours are:\n`
|
|
336
|
+
+ who.sites.map(s => ` ${s.slug}${s.name && s.name !== s.slug ? ` (${s.name})` : ""}`).join("\n"));
|
|
337
|
+
}
|
|
338
|
+
return hit;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const saved = await findProject(dir);
|
|
342
|
+
if (saved) {
|
|
343
|
+
const hit = who.sites.find(s => s.id === saved.project);
|
|
344
|
+
if (hit) return hit;
|
|
345
|
+
info(`${saved.file} names a project this key cannot reach. Choose again.`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const chosen = await pick(
|
|
349
|
+
` ${c.bold("Which project?")}`, who.sites, label,
|
|
350
|
+
list => `No terminal to choose with. Pass --project, for example:\n`
|
|
351
|
+
+ ` aloic deploy ${args[1] && !args[1].startsWith("--") ? args[1] : "."}`
|
|
352
|
+
+ ` --project ${list[0].slug}`
|
|
353
|
+
);
|
|
354
|
+
if (save) {
|
|
355
|
+
const file = await saveProject(dir, chosen.id, chosen.slug);
|
|
356
|
+
info(`Remembered in ${file}. Commit it and the whole team deploys the same project.`);
|
|
357
|
+
}
|
|
358
|
+
return chosen;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ---------- commands ---------- */
|
|
362
|
+
|
|
363
|
+
async function cmdLogin() {
|
|
364
|
+
const found = await findKey();
|
|
365
|
+
if (found && !has("force")) {
|
|
366
|
+
const who = await signIn(found.key).catch(() => null);
|
|
367
|
+
if (who) {
|
|
368
|
+
ok(`Already signed in${who.sites?.length ? ` with ${who.sites.length} project${who.sites.length === 1 ? "" : "s"}` : ""}.`);
|
|
369
|
+
info("Run `aloic login --force` to sign in again.");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
await login({});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function cmdLogout() {
|
|
377
|
+
const found = await findKey();
|
|
378
|
+
if (!found) { ok("Not signed in on this machine."); return; }
|
|
379
|
+
|
|
380
|
+
/* A KEY FROM THE ENVIRONMENT IS NOT THIS MACHINE'S TO REVOKE. It was put
|
|
381
|
+
there by a pipeline or a shell profile, other machines may be using the
|
|
382
|
+
same one, and deleting it on the way out of an unrelated command would
|
|
383
|
+
take a deployment pipeline down. Only the one `login` saved is revoked. */
|
|
384
|
+
if (found.from !== configPath()) {
|
|
385
|
+
await clearConfig();
|
|
386
|
+
ok("Signed out on this machine.");
|
|
387
|
+
info(`The key in ${found.from} is left alone. Revoke it in Settings on aloic.ai.`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const who = await signIn(found.key).catch(() => null);
|
|
392
|
+
const gone = who ? await revoke(found.key, who.idToken).catch(() => false) : false;
|
|
393
|
+
await clearConfig();
|
|
394
|
+
|
|
395
|
+
if (gone) {
|
|
396
|
+
ok("Terminal key revoked.");
|
|
397
|
+
info("Run `aloic login` to sign in again.");
|
|
398
|
+
} else {
|
|
399
|
+
ok("Signed out on this machine.");
|
|
400
|
+
info("The key could not be revoked from here. Remove it in Settings on aloic.ai.");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* ---------- taking it back off ----------
|
|
405
|
+
*
|
|
406
|
+
* EVERY INSTALLER OWES YOU ONE. A tool that writes to three places in your
|
|
407
|
+
* home directory and a line into your shell profile, and then has no way to
|
|
408
|
+
* undo any of it, is a tool you have to clean up by hand from a blog post. It
|
|
409
|
+
* is also the thing that makes the install worth trusting: an install you can
|
|
410
|
+
* reverse in one command is an install you can try.
|
|
411
|
+
*
|
|
412
|
+
* There is a shell version of this in the installer as well, for the case this
|
|
413
|
+
* one cannot help with, which is a copy too broken to run. */
|
|
414
|
+
async function cmdUninstall() {
|
|
415
|
+
const dir = process.env.ALOIC_HOME || join(homedir(), ".aloic");
|
|
416
|
+
const launcher = join(homedir(), ".local", "bin", "aloic");
|
|
417
|
+
const keepKey = has("keep-key");
|
|
418
|
+
|
|
419
|
+
/* Was this copy put here by the installer, or is it npx running out of a
|
|
420
|
+
cache somewhere? Only the first case owns the directories below. */
|
|
421
|
+
const mine = here.startsWith(resolve(dir) + sep);
|
|
422
|
+
|
|
423
|
+
const targets = [];
|
|
424
|
+
const seen = async p => { try { await stat(p); return true; } catch { return false; } };
|
|
425
|
+
if (await seen(join(dir, "versions"))) targets.push(join(dir, "versions"));
|
|
426
|
+
if (await seen(join(dir, "current"))) targets.push(join(dir, "current"));
|
|
427
|
+
/* The file the installer writes for `source ~/.aloic/env`. Left behind, it
|
|
428
|
+
is one stale line pointing at a directory that no longer has anything in
|
|
429
|
+
it, and it keeps the directory from being tidied away. */
|
|
430
|
+
if (await seen(join(dir, "env"))) targets.push(join(dir, "env"));
|
|
431
|
+
if (await seen(launcher)) targets.push(launcher);
|
|
432
|
+
|
|
433
|
+
if (!targets.length && !mine) {
|
|
434
|
+
out("");
|
|
435
|
+
info("This copy was not installed by the Aloic installer, so there is nothing here to remove.");
|
|
436
|
+
info("If you installed it with npm, remove it with `npm rm -g aloic`.");
|
|
437
|
+
if (await seen(configPath())) {
|
|
438
|
+
out("");
|
|
439
|
+
info(`Your saved key is still at ${configPath()}. Remove it with \`aloic logout\`.`);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
out("");
|
|
445
|
+
out(` ${c.bold("Uninstall Aloic")}`);
|
|
446
|
+
out("");
|
|
447
|
+
for (const t of targets) out(` ${c.grey("remove")} ${t}`);
|
|
448
|
+
if (!keepKey && await seen(configPath())) {
|
|
449
|
+
out(` ${c.grey("remove")} ${configPath()} ${c.grey("(your saved key)")}`);
|
|
450
|
+
}
|
|
451
|
+
out(` ${c.grey("remove")} the PATH line from your shell profile ${c.grey("(if it is there)")}`);
|
|
452
|
+
out("");
|
|
453
|
+
|
|
454
|
+
/* IT ASKS, AND WHERE IT CANNOT ASK IT REFUSES.
|
|
455
|
+
|
|
456
|
+
Every other command in this tool treats "no terminal" as "get on with it",
|
|
457
|
+
which is right for publishing and wrong for deleting: a command that
|
|
458
|
+
removes the tool, the key and a line from a shell profile must not do that
|
|
459
|
+
because it happened to be run from a script. With nobody to answer, the
|
|
460
|
+
answer has to be on the command line. */
|
|
461
|
+
if (!has("yes")) {
|
|
462
|
+
if (!tty()) {
|
|
463
|
+
die("Uninstalling needs a terminal to confirm from. Pass --yes to skip the question.");
|
|
464
|
+
}
|
|
465
|
+
if (!await confirm(" Are you sure you want to uninstall the Aloic CLI?", false)) {
|
|
466
|
+
out("");
|
|
467
|
+
info("Nothing was removed.");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* TWO STEPS, NAMED, IN THE ORDER THEY HAPPEN.
|
|
473
|
+
|
|
474
|
+
Signing out is a network round trip and deleting files is not, so without
|
|
475
|
+
saying which one is running the whole thing is a pause followed by a tick.
|
|
476
|
+
They are also genuinely different acts: the first gives a credential back
|
|
477
|
+
to the server, the second takes files off this machine, and somebody
|
|
478
|
+
watching should be able to tell which one failed. */
|
|
479
|
+
out("");
|
|
480
|
+
|
|
481
|
+
/* THE KEY GOES BACK FIRST, while there is still a tool to do it with. A
|
|
482
|
+
local file deleted is a key that goes on working on the server, listed in
|
|
483
|
+
Settings, until somebody notices it. Best effort: being offline is not a
|
|
484
|
+
reason to refuse to uninstall. */
|
|
485
|
+
if (!keepKey) {
|
|
486
|
+
const found = await findKey();
|
|
487
|
+
if (found && found.from === configPath()) {
|
|
488
|
+
const s1 = tty() ? spin("Logging out") : null;
|
|
489
|
+
const who = await signIn(found.key).catch(() => null);
|
|
490
|
+
const gone = who ? await revoke(found.key, who.idToken).catch(() => false) : false;
|
|
491
|
+
s1?.stop();
|
|
492
|
+
if (gone) ok("Terminal key revoked.");
|
|
493
|
+
else info("The key could not be revoked from here. Remove it in Settings on aloic.ai.");
|
|
494
|
+
} else if (found) {
|
|
495
|
+
info(`The key in ${found.from} is left alone. Revoke it in Settings on aloic.ai.`);
|
|
496
|
+
}
|
|
497
|
+
await clearConfig();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const s2 = tty() ? spin("Uninstalling") : null;
|
|
501
|
+
for (const t of targets) await rm(t, { recursive: true, force: true }).catch(() => {});
|
|
502
|
+
/* Only if it is empty. Somebody may keep other things in here, and an
|
|
503
|
+
uninstaller that takes a directory it did not create is a bug. */
|
|
504
|
+
await rmdir(dir).catch(() => {});
|
|
505
|
+
|
|
506
|
+
const rc = await stripPath();
|
|
507
|
+
s2?.stop();
|
|
508
|
+
|
|
509
|
+
ok("Aloic removed.");
|
|
510
|
+
if (rc) info(`The PATH line was taken out of ${rc}. Open a new terminal.`);
|
|
511
|
+
else info("Nothing was left in your shell profile.");
|
|
512
|
+
out("");
|
|
513
|
+
out(` ${c.grey("Install it again with")} ${c.cyan("curl -fsSL https://get.aloic.ai | sh")}`);
|
|
514
|
+
out("");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/* The two lines the installer appended, and only those two. Rewritten rather
|
|
518
|
+
than truncated, so anything somebody added afterwards survives. */
|
|
519
|
+
async function stripPath() {
|
|
520
|
+
const files = [".zshrc", ".bashrc", ".bash_profile", ".profile",
|
|
521
|
+
join(".config", "fish", "config.fish")];
|
|
522
|
+
for (const name of files) {
|
|
523
|
+
const at = join(homedir(), name);
|
|
524
|
+
let text;
|
|
525
|
+
try { text = await readFile(at, "utf8"); } catch { continue; }
|
|
526
|
+
if (!text.includes("# aloic installer")) continue;
|
|
527
|
+
const next = text.replace(
|
|
528
|
+
/\n*# aloic installer\n(?:export PATH=[^\n]*\n|fish_add_path[^\n]*\n)?/g,
|
|
529
|
+
"\n"
|
|
530
|
+
);
|
|
531
|
+
if (next === text) continue;
|
|
532
|
+
await writeFile(at, next);
|
|
533
|
+
return at;
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/* ---------- changing them later ----------
|
|
539
|
+
*
|
|
540
|
+
* The setup screen in the browser is where these are first chosen, and a
|
|
541
|
+
* choice you can only revisit by signing in again is a choice you will live
|
|
542
|
+
* with instead. Same three questions, asked the way a terminal asks them. */
|
|
543
|
+
async function cmdSettings() {
|
|
544
|
+
const now = await settings();
|
|
545
|
+
const vals = s => s.values || [true, false];
|
|
546
|
+
const label = (s, v) => (v === vals(s)[0] ? s.on : s.off);
|
|
547
|
+
|
|
548
|
+
if (!tty()) {
|
|
549
|
+
out("");
|
|
550
|
+
for (const s of SETTINGS) {
|
|
551
|
+
out(` ${c.grey(s.q.padEnd(28))}${label(s, now[s.key])}`);
|
|
552
|
+
}
|
|
553
|
+
out("");
|
|
554
|
+
info("Run this in a terminal to change them.");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
out("");
|
|
559
|
+
const next = { ...now };
|
|
560
|
+
for (const s of SETTINGS) {
|
|
561
|
+
const chosen = await pick(
|
|
562
|
+
` ${c.bold(s.q)}`,
|
|
563
|
+
vals(s).map(v => ({ v, t: label(s, v) })),
|
|
564
|
+
it => (it.v === now[s.key] ? `${it.t} ${c.grey("(current)")}` : it.t)
|
|
565
|
+
);
|
|
566
|
+
next[s.key] = chosen.v;
|
|
567
|
+
/* The footnote belongs to an ANSWER, not to the question, so it appears
|
|
568
|
+
only for the person it is about. */
|
|
569
|
+
if (s.note && chosen.v === vals(s)[0]) info(c.grey(s.note));
|
|
570
|
+
}
|
|
571
|
+
await saveConfig({ settings: next });
|
|
572
|
+
out("");
|
|
573
|
+
ok("Saved.");
|
|
574
|
+
info(`Stored in ${configPath()}`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/* ---------- moving to a newer version ----------
|
|
578
|
+
*
|
|
579
|
+
* The manual half of the update setting. Somebody who chose "update when I
|
|
580
|
+
* ask" needs something to ask with, and somebody on automatic still wants a
|
|
581
|
+
* way to do it now rather than after the next command. */
|
|
582
|
+
async function cmdUpdate() {
|
|
583
|
+
const { installed, installVersion, latest, writable, matches } =
|
|
584
|
+
await import("./lib/selfupdate.mjs");
|
|
585
|
+
|
|
586
|
+
if (!installed() || !await writable()) {
|
|
587
|
+
out("");
|
|
588
|
+
info("This copy was not installed by the Aloic installer, so it cannot update itself.");
|
|
589
|
+
info("Reinstall with: curl -fsSL https://get.aloic.ai | sh");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const s = tty() ? spin("Checking for a newer version") : null;
|
|
594
|
+
const v = await latest();
|
|
595
|
+
s?.stop();
|
|
596
|
+
|
|
597
|
+
if (!v) { bad("Could not reach get.aloic.ai."); process.exit(1); }
|
|
598
|
+
|
|
599
|
+
/* "ALREADY ON IT" HAS TO MEAN THE FILES ARE THE ONES PUBLISHED, not that a
|
|
600
|
+
number matches. A release that was overwritten in place, which happened
|
|
601
|
+
once and is now refused at the packing end, leaves an install with the
|
|
602
|
+
right version number and the wrong contents, and a check that compares
|
|
603
|
+
only the number can never get it out of that. This compares what is on
|
|
604
|
+
disk against the manifest and repairs it when they differ. */
|
|
605
|
+
if (v === VERSION && await matches(v)) {
|
|
606
|
+
ok(`Already on ${c.bold(VERSION)}.`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (v === VERSION) info("This copy does not match the published release. Repairing.");
|
|
610
|
+
|
|
611
|
+
const s2 = tty() ? spin(`Updating to ${v}`) : null;
|
|
612
|
+
try {
|
|
613
|
+
await installVersion(v);
|
|
614
|
+
s2?.stop();
|
|
615
|
+
ok(`Updated ${c.grey(VERSION)} ${c.grey("\u2192")} ${c.bold(v)}`);
|
|
616
|
+
} catch (e) {
|
|
617
|
+
s2?.stop();
|
|
618
|
+
bad(e?.message || "That update did not go through.");
|
|
619
|
+
info(`Still on ${VERSION}. Nothing was changed.`);
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function cmdWhoami() {
|
|
625
|
+
const who = await sessionOrLogin({ quiet: true });
|
|
626
|
+
const from = (await findKey())?.from;
|
|
627
|
+
out(`${c.bold(who.email || "signed in")}`);
|
|
628
|
+
out(`${c.grey("key from")} ${from}`);
|
|
629
|
+
out(`${c.grey("projects")} ${who.sites?.length || 0}`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function cmdProjects() {
|
|
633
|
+
const who = await sessionOrLogin({ quiet: true });
|
|
634
|
+
if (!who.sites?.length) return info("No projects on this account yet.");
|
|
635
|
+
/* The slug is what --project wants, and three unlabelled columns left
|
|
636
|
+
somebody guessing which of them that was. */
|
|
637
|
+
const wide = Math.max(...who.sites.map(s => (s.name || s.slug).length));
|
|
638
|
+
out(` ${c.grey("NAME".padEnd(wide))} ${c.grey("--project")}`);
|
|
639
|
+
for (const s of who.sites) {
|
|
640
|
+
const live = s.status === "live" ? c.green("live") : c.grey(s.status || "draft");
|
|
641
|
+
out(` ${(s.name || s.slug).padEnd(wide)} ${c.bold(s.slug)} ${live}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function cmdInit() {
|
|
646
|
+
const who = await sessionOrLogin({});
|
|
647
|
+
const chosen = await chooseProject(who, { save: true });
|
|
648
|
+
/* WRITTEN HERE TOO, and this is the fix for a real lie. chooseProject only
|
|
649
|
+
saves the choice it had to ASK for, so `aloic init --project x` returned a
|
|
650
|
+
green tick, wrote nothing, and the very next deploy prompted again. An
|
|
651
|
+
init that does not persist is worse than one that fails, because it fails
|
|
652
|
+
one command later and somewhere else. */
|
|
653
|
+
const file = await saveProject(process.cwd(), chosen.id, chosen.slug);
|
|
654
|
+
ok(`This folder publishes to ${c.bold(chosen.name || chosen.slug)}.`);
|
|
655
|
+
info(`Written to ${file}. Commit it and the whole team deploys the same project.`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function cmdDeploy() {
|
|
659
|
+
/* `aloic deploy draft` is the finish of the sentence `aloic deploy --draft`
|
|
660
|
+
started, so it is spelled the way the earlier command printed it. The
|
|
661
|
+
keyword wins over a folder of the same name: deploying a directory called
|
|
662
|
+
exactly "draft" is vanishingly rare and still one character away as
|
|
663
|
+
`aloic deploy ./draft`, whereas the keyword silently deploying a folder
|
|
664
|
+
would publish the wrong thing without saying so. */
|
|
665
|
+
if (args[1] === "draft") return await cmdDraft("publish");
|
|
666
|
+
|
|
667
|
+
const quiet = has("quiet");
|
|
668
|
+
/* THE CLOCK STARTS WHEN THE WORK DOES, not when the command was typed.
|
|
669
|
+
|
|
670
|
+
This was set here, above the sign in, the project picker and the prompt
|
|
671
|
+
for a title, so every second somebody spent reading a list or thinking of
|
|
672
|
+
a name was billed to the deploy: a build that took four seconds reported
|
|
673
|
+
forty because the person answering the prompt was slow, and that number is
|
|
674
|
+
stored on the record and shown in the deploy list forever.
|
|
675
|
+
|
|
676
|
+
Nothing above the first note is work. It is a conversation. */
|
|
677
|
+
let began = Date.now();
|
|
678
|
+
const log = [];
|
|
679
|
+
const note = (m, k) => {
|
|
680
|
+
log.push({ at: Date.now() - began, m, ...(k ? { k } : {}) });
|
|
681
|
+
if (!quiet) out(k === "step" ? `\n${c.bold(m)}` : ` ${c.grey(m)}`);
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const where = resolve(args[1] && !args[1].startsWith("--") ? args[1] : ".");
|
|
685
|
+
|
|
686
|
+
const who = await sessionOrLogin({ quiet });
|
|
687
|
+
|
|
688
|
+
/* WHAT THIS MACHINE WAS TOLD TO DO, and the flags still win.
|
|
689
|
+
--draft and --publish are one command saying what it wants; the setting is
|
|
690
|
+
what every command means when it says nothing. */
|
|
691
|
+
const prefs = await settings();
|
|
692
|
+
const live = has("publish") ? true : has("draft") ? false : prefs.publish !== false;
|
|
693
|
+
|
|
694
|
+
const site = await chooseProject(who, { save: live });
|
|
695
|
+
|
|
696
|
+
/* REQUIRED. A deploy with no name is a row in a list that says "Upload", and
|
|
697
|
+
a list of those is a history nobody can read, which is most of the reason
|
|
698
|
+
to keep a history. Asked for until it is answered when somebody is there;
|
|
699
|
+
refused outright when nobody is, because a pipeline that publishes
|
|
700
|
+
anonymous builds is the case this is for. */
|
|
701
|
+
/* `--message` still answers. It was the name first, and the GitHub Action
|
|
702
|
+
people have already copied into their repositories passes it: a rename
|
|
703
|
+
that breaks somebody's pipeline is not a rename, it is an outage. */
|
|
704
|
+
let title = (flag("title") || flag("message") || "").trim();
|
|
705
|
+
if (!title) {
|
|
706
|
+
if (!tty()) {
|
|
707
|
+
die("This deploy needs a title. Pass --title \"what this is\".");
|
|
708
|
+
}
|
|
709
|
+
out("");
|
|
710
|
+
while (!title) {
|
|
711
|
+
title = await ask(` ${c.bold("Title")}`, "Name this deployment.");
|
|
712
|
+
if (!title) info("A title is needed.");
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* ASKED BEFORE ANYTHING MOVES, for whoever asked to be asked. After the
|
|
717
|
+
title, so the question names the thing it is about, and before the upload,
|
|
718
|
+
because stopping half way through one is not a decision anybody wanted to
|
|
719
|
+
have offered to them. */
|
|
720
|
+
/* WHERE THERE IS NOBODY TO ASK, IT DOES NOT ASK.
|
|
721
|
+
This used to refuse and name a flag. Confirming before publishing is a
|
|
722
|
+
personal preference somebody set on their own machine, not a safety rail
|
|
723
|
+
the product depends on, and a preference that stops a build machine is a
|
|
724
|
+
preference that has escaped its scope. Deleting things still asks: see
|
|
725
|
+
cmdUninstall, which refuses precisely because it is not a preference. */
|
|
726
|
+
if (live && prefs.confirm && tty()) {
|
|
727
|
+
const where = site.primaryDomain || `${site.slug}.aloic.ai`;
|
|
728
|
+
out("");
|
|
729
|
+
if (!await confirm(` Publish to ${c.bold(where)}?`)) {
|
|
730
|
+
out("");
|
|
731
|
+
info("Nothing was uploaded.");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/* Everything above was somebody answering questions. Time starts here. */
|
|
737
|
+
began = Date.now();
|
|
738
|
+
|
|
739
|
+
note("Reading files", "step");
|
|
740
|
+
const { files, skipped } = await walk(where);
|
|
741
|
+
const wrong = tooMuch(files);
|
|
742
|
+
if (wrong) die(wrong);
|
|
743
|
+
const bytes = files.reduce((n, f) => n + f.size, 0);
|
|
744
|
+
note(`${files.length} ${files.length === 1 ? "file" : "files"}, ${(bytes / 1024).toFixed(0)}KB`);
|
|
745
|
+
if (skipped.length) {
|
|
746
|
+
note(`${skipped.length} not included: ${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? "…" : ""}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/* EVERYTHING IS HASHED FIRST, then compared against what is already stored,
|
|
750
|
+
and only what is missing goes up. On a second deploy of a site whose
|
|
751
|
+
images did not change, that is the difference between sending the whole
|
|
752
|
+
build and sending the one file that was edited. */
|
|
753
|
+
const blobs = new Map();
|
|
754
|
+
const map = {};
|
|
755
|
+
for (const f of files) {
|
|
756
|
+
const buf = await read(f);
|
|
757
|
+
f.hash = hashOf(buf);
|
|
758
|
+
map[keyOf(f.path)] = f.hash;
|
|
759
|
+
if (!blobs.has(f.hash)) blobs.set(f.hash, { buf, type: typeOf(f.path) });
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const already = await have(who.uid, site.id, who.idToken);
|
|
763
|
+
const todo = [...blobs.entries()].filter(([h]) => !already.has(h));
|
|
764
|
+
const reused = blobs.size - todo.length;
|
|
765
|
+
|
|
766
|
+
note("Uploading", "step");
|
|
767
|
+
if (reused) note(`${reused} already stored, not sent again`);
|
|
768
|
+
|
|
769
|
+
let sent = 0, failed = null, at = 0;
|
|
770
|
+
const s = todo.length && !quiet && tty() ? spin("") : null;
|
|
771
|
+
const paint = () => s?.set(`${bar(sent, todo.length)} ${sent}/${todo.length}`);
|
|
772
|
+
paint();
|
|
773
|
+
|
|
774
|
+
/* Four at a time. Enough to keep a connection busy, few enough that a
|
|
775
|
+
failure is reported before much more has been spent on it. */
|
|
776
|
+
await Promise.all(Array.from({ length: Math.min(4, todo.length) }, async () => {
|
|
777
|
+
for (;;) {
|
|
778
|
+
if (failed) return;
|
|
779
|
+
const item = todo[at++];
|
|
780
|
+
if (!item) return;
|
|
781
|
+
const [hash, { buf, type }] = item;
|
|
782
|
+
try { await putBlob(who.uid, site.id, who.idToken, hash, type, buf); }
|
|
783
|
+
catch (e) { failed = e; return; }
|
|
784
|
+
sent++; paint();
|
|
785
|
+
}
|
|
786
|
+
}));
|
|
787
|
+
s?.stop();
|
|
788
|
+
if (failed) die(`Upload failed. ${failed.message}`);
|
|
789
|
+
note(`Sent ${todo.length} ${todo.length === 1 ? "file" : "files"} in ${((Date.now() - began) / 1000).toFixed(1)}s`);
|
|
790
|
+
|
|
791
|
+
/* THE ID ALOIC ALREADY PICKED, when there is one.
|
|
792
|
+
*
|
|
793
|
+
* A build started from the dashboard creates the deploy record first, so
|
|
794
|
+
* there is a page to watch from the moment the button is pressed, and passes
|
|
795
|
+
* its id to the workflow. Filling that record in is the difference between
|
|
796
|
+
* one deploy that goes from building to ready and two rows where one of them
|
|
797
|
+
* says "building" forever.
|
|
798
|
+
*
|
|
799
|
+
* Only ever from the environment, and only a shape we would have generated
|
|
800
|
+
* ourselves: this decides which document gets written, and the write is
|
|
801
|
+
* scoped to this project by the key either way. A push-triggered build sends
|
|
802
|
+
* nothing and makes its own, exactly as before. */
|
|
803
|
+
const given = String(process.env.ALOIC_DEPLOYMENT || "").trim();
|
|
804
|
+
const deployId = /^[A-Za-z0-9_-]{16,32}$/.test(given)
|
|
805
|
+
? given
|
|
806
|
+
: randomBytes(15).toString("base64url");
|
|
807
|
+
await putMap(who.uid, site.id, who.idToken, deployId, map);
|
|
808
|
+
|
|
809
|
+
/* SAID OUT LOUD, because it is a change to the files somebody just gave us
|
|
810
|
+
and finding it by curling your own site is the wrong way to learn it. */
|
|
811
|
+
note("Aloic adds a small analytics script to served HTML pages", "info");
|
|
812
|
+
|
|
813
|
+
if (live) {
|
|
814
|
+
note("Publishing", "step");
|
|
815
|
+
note(`Pointing ${site.primaryDomain || `${site.slug}.aloic.ai`} at this deploy`);
|
|
816
|
+
} else {
|
|
817
|
+
note("Uploaded", "step");
|
|
818
|
+
note("Not published. The project keeps serving what it was.");
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
await publish(who.idToken, site.id, deployId, {
|
|
822
|
+
id: deployId,
|
|
823
|
+
siteId: site.id,
|
|
824
|
+
uid: who.uid,
|
|
825
|
+
status: "ready",
|
|
826
|
+
files: files.length,
|
|
827
|
+
bytes,
|
|
828
|
+
createdAt: began,
|
|
829
|
+
ms: Date.now() - began,
|
|
830
|
+
root: "",
|
|
831
|
+
skipped: skipped.length,
|
|
832
|
+
/* THE MESSAGE NAMES THE DEPLOY. It is the commit subject in a pipeline and
|
|
833
|
+
the one line somebody writes by hand otherwise, which is a name rather
|
|
834
|
+
than a description, and it is what the deploy page shows as its
|
|
835
|
+
heading. --description is the longer form underneath. */
|
|
836
|
+
title: title.slice(0, 120),
|
|
837
|
+
note: (flag("description") || "").trim().slice(0, 500),
|
|
838
|
+
/* Says it was never published, which is not the same as having been
|
|
839
|
+
replaced. See the note on `draft` in lib/creators.ts. */
|
|
840
|
+
...(live ? {} : { draft: true }),
|
|
841
|
+
expiresAt: null,
|
|
842
|
+
log,
|
|
843
|
+
/* Says where it came from, so a deploy page can tell a push from a drop. */
|
|
844
|
+
via: "cli"
|
|
845
|
+
}, live);
|
|
846
|
+
|
|
847
|
+
const url = `https://${site.primaryDomain || `${site.slug}.aloic.ai`}`;
|
|
848
|
+
const took = c.grey(`${((Date.now() - began) / 1000).toFixed(1)}s`);
|
|
849
|
+
if (quiet) out(live ? url : deployId);
|
|
850
|
+
else {
|
|
851
|
+
out("");
|
|
852
|
+
/* A DRAFT IS NOT LIVE, and saying so would be the one lie this tool tells.
|
|
853
|
+
It printed the address either way, which read as a successful publish of
|
|
854
|
+
something that was deliberately not published. */
|
|
855
|
+
if (live) ok(`Live at ${c.cyan(url)} ${took}`);
|
|
856
|
+
else {
|
|
857
|
+
ok(`Draft created ${took}`);
|
|
858
|
+
info(`Your previous deployment is still published at ${c.cyan(url)}.`);
|
|
859
|
+
/* WHAT TO DO WITH IT, NAMED. A draft is the one deploy that is not
|
|
860
|
+
finished with, and a tool that makes one and then says nothing about
|
|
861
|
+
how to look at it or send it leaves somebody in the dashboard hunting
|
|
862
|
+
for a button. Two commands, both of which act on this draft. */
|
|
863
|
+
out("");
|
|
864
|
+
out(` ${c.grey("Preview it in a browser")}`);
|
|
865
|
+
out(` ${c.bold("aloic test draft")}`);
|
|
866
|
+
out("");
|
|
867
|
+
out(` ${c.grey(`Publish it to ${url}`)}`);
|
|
868
|
+
out(` ${c.bold("aloic deploy draft")}`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/* ---------- finishing with a draft ----------
|
|
874
|
+
*
|
|
875
|
+
* TWO ENDINGS FOR ONE THING, and they are the two sentences the deploy that
|
|
876
|
+
* made it printed: look at it, or send it. Both act on the newest draft of
|
|
877
|
+
* whichever project this folder publishes to, because that is what somebody
|
|
878
|
+
* means by "the draft" one command after making one.
|
|
879
|
+
*/
|
|
880
|
+
async function cmdDraft(what) {
|
|
881
|
+
const who = await sessionOrLogin({});
|
|
882
|
+
const site = await chooseProject(who, { save: false });
|
|
883
|
+
|
|
884
|
+
const { newestDraft, publishDraft, setPreview, PREVIEW_FOR } =
|
|
885
|
+
await import("./lib/api.mjs");
|
|
886
|
+
|
|
887
|
+
/* NOT CAUGHT AND FLATTENED TO null. A failed lookup and an empty project are
|
|
888
|
+
different facts, and reporting the first as the second is how a broken
|
|
889
|
+
query spent a release telling people they had no draft when they were
|
|
890
|
+
looking at one. */
|
|
891
|
+
const s0 = tty() ? spin("Looking for a draft") : null;
|
|
892
|
+
let d = null;
|
|
893
|
+
try {
|
|
894
|
+
d = await newestDraft(who.idToken, site.id);
|
|
895
|
+
s0?.stop();
|
|
896
|
+
} catch (e) {
|
|
897
|
+
s0?.stop();
|
|
898
|
+
die(e?.message || "Could not look for a draft.");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (!d) {
|
|
902
|
+
out("");
|
|
903
|
+
bad(`${site.name || site.slug} has no draft waiting.`);
|
|
904
|
+
info("Make one with `aloic deploy --draft`.");
|
|
905
|
+
process.exit(1);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const name = (d.title || d.note || "").trim() || d.id.slice(0, 8);
|
|
909
|
+
const url = `https://${site.primaryDomain || `${site.slug}.aloic.ai`}`;
|
|
910
|
+
|
|
911
|
+
if (what === "publish") {
|
|
912
|
+
out("");
|
|
913
|
+
out(` ${c.bold(name)} ${c.grey(`${d.files} files, ${(d.bytes / 1024).toFixed(0)}KB`)}`);
|
|
914
|
+
out("");
|
|
915
|
+
|
|
916
|
+
/* THE SAME SETTING THE UPLOAD PATH READS. Somebody who asked to be asked
|
|
917
|
+
before publishing meant every publish, not only the ones that happen to
|
|
918
|
+
come with an upload attached. */
|
|
919
|
+
const prefs = await settings();
|
|
920
|
+
if (prefs.confirm && tty()) {
|
|
921
|
+
if (!await confirm(` Publish to ${c.bold(url.replace(/^https:\/\//, ""))}?`)) {
|
|
922
|
+
out("");
|
|
923
|
+
info("Nothing was published.");
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
out("");
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const s = tty() ? spin("Publishing") : null;
|
|
930
|
+
try {
|
|
931
|
+
await publishDraft(who.idToken, site.id, d.id);
|
|
932
|
+
s?.stop();
|
|
933
|
+
} catch (e) {
|
|
934
|
+
s?.stop();
|
|
935
|
+
die(e?.message || "That draft could not be published.");
|
|
936
|
+
}
|
|
937
|
+
ok(`Live at ${c.cyan(url)}`);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/* ---------- test ----------
|
|
942
|
+
*
|
|
943
|
+
* A preview is a whole host of its own rather than a path, so an absolute
|
|
944
|
+
* link inside the build resolves inside the preview: see previewUrl in
|
|
945
|
+
* src/lib/creators.ts. It is minted here and stopped when this command
|
|
946
|
+
* ends, so the address lives exactly as long as somebody is looking at it. */
|
|
947
|
+
const key = previewName();
|
|
948
|
+
const until = Date.now() + PREVIEW_FOR;
|
|
949
|
+
const s = tty() ? spin("Making a preview") : null;
|
|
950
|
+
try {
|
|
951
|
+
await setPreview(who.idToken, site.id, d.id, key, until);
|
|
952
|
+
s?.stop();
|
|
953
|
+
} catch (e) {
|
|
954
|
+
s?.stop();
|
|
955
|
+
die(e?.message || "That preview could not be made.");
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const at = `https://${key}--${site.slug}.aloic.ai`;
|
|
959
|
+
out("");
|
|
960
|
+
ok(`Previewing ${c.bold(name)}`);
|
|
961
|
+
out(` ${c.cyan(at)}`);
|
|
962
|
+
out("");
|
|
963
|
+
openBrowser(at);
|
|
964
|
+
|
|
965
|
+
if (!tty()) {
|
|
966
|
+
info(`This address stops answering in ${PREVIEW_FOR / 60000} minutes.`);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/* THE CLOCK IS THE POINT. A preview that quietly expires is a link somebody
|
|
971
|
+
sends and then has to explain; one that counts down in front of you is a
|
|
972
|
+
thing you know the shape of. Enter ends it early. */
|
|
973
|
+
const ring = spin("", "");
|
|
974
|
+
const tick = () => {
|
|
975
|
+
const left = Math.max(0, until - Date.now());
|
|
976
|
+
const m = Math.floor(left / 60000), sec = Math.floor((left % 60000) / 1000);
|
|
977
|
+
ring.set(`Preview open for ${c.bold(`${m}:${String(sec).padStart(2, "0")}`)}`);
|
|
978
|
+
ring.say("Press Enter to stop the preview.");
|
|
979
|
+
};
|
|
980
|
+
tick();
|
|
981
|
+
const beat = setInterval(tick, 1000);
|
|
982
|
+
|
|
983
|
+
await Promise.race([
|
|
984
|
+
ask(""),
|
|
985
|
+
new Promise(r => setTimeout(r, Math.max(0, until - Date.now())))
|
|
986
|
+
]);
|
|
987
|
+
clearInterval(beat);
|
|
988
|
+
ring.stop();
|
|
989
|
+
|
|
990
|
+
/* Ended deliberately rather than left to run out, which is the difference
|
|
991
|
+
between a link that is dead and a link that is dead in half an hour. */
|
|
992
|
+
await setPreview(who.idToken, site.id, d.id, key, 0).catch(() => {});
|
|
993
|
+
ok("Preview stopped.");
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/* Lowercase and digits, because a host is case insensitive and a deploy id is
|
|
997
|
+
not. Random, so a preview cannot be guessed from an id anybody can list. */
|
|
998
|
+
function previewName() {
|
|
999
|
+
return randomBytes(9).toString("hex").slice(0, 12);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/* ---------- running ---------- */
|
|
1003
|
+
|
|
1004
|
+
if (has("version") || cmd === "version") { out(VERSION); process.exit(0); }
|
|
1005
|
+
|
|
1006
|
+
const commands = {
|
|
1007
|
+
login: cmdLogin, logout: cmdLogout, whoami: cmdWhoami,
|
|
1008
|
+
projects: cmdProjects, init: cmdInit, deploy: cmdDeploy,
|
|
1009
|
+
settings: cmdSettings, update: cmdUpdate, uninstall: cmdUninstall,
|
|
1010
|
+
test: () => cmdDraft("test")
|
|
1011
|
+
};
|
|
1012
|
+
const run = commands[cmd];
|
|
1013
|
+
|
|
1014
|
+
/* ---------- `aloic`, on its own ----------
|
|
1015
|
+
*
|
|
1016
|
+
* IT CHECKS WHETHER IT IS SET UP, which is what makes it the one thing the
|
|
1017
|
+
* installer has to tell anybody to run. Bare, with no key on this machine, the
|
|
1018
|
+
* useful thing is not a list of commands that will all refuse: it is the setup
|
|
1019
|
+
* that makes them work. With a key, it is the list.
|
|
1020
|
+
*
|
|
1021
|
+
* Only ever with no arguments at all. `aloic deploy` on an unconfigured
|
|
1022
|
+
* machine already asks for a sign-in on its way past, and `aloic --help` is
|
|
1023
|
+
* somebody asking for the list rather than for anything to happen. */
|
|
1024
|
+
if (!cmd && !has("help")) {
|
|
1025
|
+
const already = await findKey();
|
|
1026
|
+
if (!already) {
|
|
1027
|
+
await login({ silent: true, bare: true });
|
|
1028
|
+
out("");
|
|
1029
|
+
out(HELP);
|
|
1030
|
+
process.exit(0);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/* A COMMAND NOBODY HAS IS NOT A REQUEST FOR THE MANUAL.
|
|
1035
|
+
*
|
|
1036
|
+
* Every unrecognised word printed the whole help page, which reads as though
|
|
1037
|
+
* the command worked and this is its output: the one line saying it was not
|
|
1038
|
+
* understood is the first thing scrolled off the top by the forty lines
|
|
1039
|
+
* underneath it. Say the one thing that is true and point at the list. */
|
|
1040
|
+
if (cmd && !run && cmd !== "help") {
|
|
1041
|
+
out("");
|
|
1042
|
+
bad(`We didn't recognize that command.`);
|
|
1043
|
+
info("Run `aloic` to see all available commands.");
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (!run || has("help") || cmd === "help") {
|
|
1048
|
+
out(HELP);
|
|
1049
|
+
process.exit(0);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/* The update check runs beside the command rather than before it, so a slow
|
|
1053
|
+
lookup cannot delay a deploy, and is only ever reported at the end.
|
|
1054
|
+
|
|
1055
|
+
NOT WHILE UNINSTALLING, and this was a real leak rather than an ordering
|
|
1056
|
+
nicety. The check records when it last ran by writing to the same config
|
|
1057
|
+
file the uninstaller has just deleted, so the two raced and the write
|
|
1058
|
+
usually landed second: every uninstall left a config.json behind in a
|
|
1059
|
+
directory it had otherwise emptied, and the next install found a stale
|
|
1060
|
+
record of a check that happened before the tool was removed. Telling
|
|
1061
|
+
somebody about a new version of the thing they are in the middle of removing
|
|
1062
|
+
would be beside the point anyway. */
|
|
1063
|
+
/* ---------- new versions ----------
|
|
1064
|
+
*
|
|
1065
|
+
* CHECKED BEFORE THE COMMAND RUNS, not after it.
|
|
1066
|
+
*
|
|
1067
|
+
* It used to be a line printed once the work was done, once a day, which is
|
|
1068
|
+
* the polite version and the wrong one: the moment somebody most wants to know
|
|
1069
|
+
* they are on an old build is before it does anything, not underneath the
|
|
1070
|
+
* output of something that has already happened. So it is asked every time and
|
|
1071
|
+
* said first.
|
|
1072
|
+
*
|
|
1073
|
+
* WHAT KEEPS THAT FROM BEING A TAX ON EVERY DEPLOY. It is one request for a
|
|
1074
|
+
* file of eight bytes, it is given two seconds and then abandoned, and it does
|
|
1075
|
+
* not happen at all without a terminal: a build machine gets no notice, no
|
|
1076
|
+
* delay and no behaviour that depends on the network being reachable.
|
|
1077
|
+
*
|
|
1078
|
+
* The commands about the tool itself are excluded, because being told an
|
|
1079
|
+
* update exists while running the thing that installs updates is the tool
|
|
1080
|
+
* talking to itself. */
|
|
1081
|
+
const quiet0 = cmd === "uninstall" || cmd === "settings" || cmd === "update";
|
|
1082
|
+
let newer = null;
|
|
1083
|
+
if (!quiet0 && tty()) {
|
|
1084
|
+
const prefs0 = await settings().catch(() => DEFAULTS);
|
|
1085
|
+
/* `false` is what the first version of this setting stored for "do not
|
|
1086
|
+
check". Nothing writes it any more and honouring it is one comparison:
|
|
1087
|
+
silently starting to check for somebody who once said not to would be
|
|
1088
|
+
changing a decision on their behalf. */
|
|
1089
|
+
if (prefs0.updates !== "off" && prefs0.updates !== false) {
|
|
1090
|
+
newer = await checkForUpdate(VERSION).catch(() => null);
|
|
1091
|
+
/* Said before anything else, and only for the mode that asked to be told.
|
|
1092
|
+
On automatic there is nothing to act on: it installs itself below. */
|
|
1093
|
+
if (newer && prefs0.updates !== "auto") {
|
|
1094
|
+
const { installed } = await import("./lib/selfupdate.mjs");
|
|
1095
|
+
tellAboutUpdate(newer, VERSION, installed() ? "aloic update" : null);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
try {
|
|
1101
|
+
await run();
|
|
1102
|
+
await afterwards(newer);
|
|
1103
|
+
} catch (e) {
|
|
1104
|
+
die(e?.message || String(e));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/* WHAT TO DO ABOUT A NEWER VERSION, once the work is done.
|
|
1108
|
+
*
|
|
1109
|
+
* On automatic it installs it here rather than before the command, so the
|
|
1110
|
+
* thing that just ran is the version that was asked for and the new one takes
|
|
1111
|
+
* over next time. Failing is not worth a red cross on an otherwise successful
|
|
1112
|
+
* deploy: it says so quietly and the tool goes on working. */
|
|
1113
|
+
async function afterwards(newer) {
|
|
1114
|
+
if (!newer) return;
|
|
1115
|
+
const prefs = await settings().catch(() => DEFAULTS);
|
|
1116
|
+
/* Only automatic acts here. Notifying already happened, before the command,
|
|
1117
|
+
which is where it is worth reading. */
|
|
1118
|
+
if (prefs.updates !== "auto") return;
|
|
1119
|
+
|
|
1120
|
+
const { installed, installVersion, writable } = await import("./lib/selfupdate.mjs");
|
|
1121
|
+
/* Never over an install we did not make, and never without somebody there:
|
|
1122
|
+
a pipeline moving itself to a version nobody pinned is the failure the
|
|
1123
|
+
note in lib/update.mjs is about. */
|
|
1124
|
+
if (!installed() || !await writable()) return tellAboutUpdate(newer, VERSION);
|
|
1125
|
+
|
|
1126
|
+
try {
|
|
1127
|
+
await installVersion(newer);
|
|
1128
|
+
out("");
|
|
1129
|
+
info(`Updated to ${c.bold(newer)}. It takes effect on the next command.`);
|
|
1130
|
+
} catch {
|
|
1131
|
+
tellAboutUpdate(newer, VERSION);
|
|
1132
|
+
}
|
|
1133
|
+
}
|