@lantharos/pult 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/cli.d.mts +1 -0
- package/cli.mjs +917 -0
- package/index.d.mts +950 -0
- package/index.mjs +950 -0
- package/package.json +23 -4
- package/schema-CzG8J_9_.mjs +638 -0
- package/stub.d.mts +11 -0
- package/stub.mjs +26 -0
package/cli.mjs
ADDED
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { C as HEADER, D as sign, E as parseKey, P as findActionDef, S as kindOf, _ as normalizeFilter, a as collect, c as guards, h as isPendingAttachment, i as validate, k as eachBlock, r as toJsonSchema } from "./schema-CzG8J_9_.mjs";
|
|
3
|
+
import { parseArgs, styleText } from "node:util";
|
|
4
|
+
import { Cron } from "croner";
|
|
5
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
6
|
+
import { glob, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
//#region ../protocol/src/diff.ts
|
|
13
|
+
function keyed(label, before, after, inner, prefix = "") {
|
|
14
|
+
const changes = [];
|
|
15
|
+
for (const name of Object.keys(after)) if (!(name in before)) changes.push({
|
|
16
|
+
kind: "add",
|
|
17
|
+
text: `adds ${label} ${prefix}${name}`
|
|
18
|
+
});
|
|
19
|
+
for (const name of Object.keys(before)) if (!(name in after)) changes.push({
|
|
20
|
+
kind: "remove",
|
|
21
|
+
text: `removes ${label} ${prefix}${name}`
|
|
22
|
+
});
|
|
23
|
+
for (const name of Object.keys(after)) {
|
|
24
|
+
const a = before[name];
|
|
25
|
+
const b = after[name];
|
|
26
|
+
if (!a || !b) continue;
|
|
27
|
+
const nested = inner?.(name, a, b) ?? [];
|
|
28
|
+
if (nested.length) changes.push(...nested);
|
|
29
|
+
else if (!sameShape(a, b)) changes.push({
|
|
30
|
+
kind: "change",
|
|
31
|
+
text: `changes ${label} ${prefix}${name}`
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return changes;
|
|
35
|
+
}
|
|
36
|
+
function sameShape(a, b) {
|
|
37
|
+
return stableJson(a, ["source"]) === stableJson(b, ["source"]);
|
|
38
|
+
}
|
|
39
|
+
function stableJson(value, omit = []) {
|
|
40
|
+
return JSON.stringify(value, (key, inner) => {
|
|
41
|
+
if (omit.includes(key)) return void 0;
|
|
42
|
+
if (inner && typeof inner === "object" && !Array.isArray(inner)) return Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a.localeCompare(b)));
|
|
43
|
+
return inner;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function listDelta(subject, verb, before, after) {
|
|
47
|
+
return [...after.filter((x) => !before.includes(x)).map((x) => ({
|
|
48
|
+
kind: "grant",
|
|
49
|
+
text: `${subject} ${verb[0]} ${x}`
|
|
50
|
+
})), ...before.filter((x) => !after.includes(x)).map((x) => ({
|
|
51
|
+
kind: "revoke",
|
|
52
|
+
text: `${subject} ${verb[1]} ${x}`
|
|
53
|
+
}))];
|
|
54
|
+
}
|
|
55
|
+
function diffManifests(before, after) {
|
|
56
|
+
const base = before ?? {
|
|
57
|
+
...after,
|
|
58
|
+
resources: {},
|
|
59
|
+
actions: {},
|
|
60
|
+
inboxes: {},
|
|
61
|
+
views: {},
|
|
62
|
+
pages: {},
|
|
63
|
+
flags: {},
|
|
64
|
+
roles: {},
|
|
65
|
+
notify: {},
|
|
66
|
+
status: null
|
|
67
|
+
};
|
|
68
|
+
return [
|
|
69
|
+
...keyed("status component", base.status?.components ?? {}, after.status?.components ?? {}),
|
|
70
|
+
...base.status && after.status && !sameShape({
|
|
71
|
+
...base.status,
|
|
72
|
+
components: null
|
|
73
|
+
}, {
|
|
74
|
+
...after.status,
|
|
75
|
+
components: null
|
|
76
|
+
}) ? [{
|
|
77
|
+
kind: "change",
|
|
78
|
+
text: "changes the status page"
|
|
79
|
+
}] : [],
|
|
80
|
+
...keyed("resource", base.resources, after.resources, (name, a, b) => {
|
|
81
|
+
const actions = keyed("action", a.actions, b.actions, actionChanges(`${name}.`), `${name}.`);
|
|
82
|
+
return [...sameShape({
|
|
83
|
+
...a,
|
|
84
|
+
actions: null
|
|
85
|
+
}, {
|
|
86
|
+
...b,
|
|
87
|
+
actions: null
|
|
88
|
+
}) ? [] : [{
|
|
89
|
+
kind: "change",
|
|
90
|
+
text: `changes resource ${name}`
|
|
91
|
+
}], ...actions];
|
|
92
|
+
}),
|
|
93
|
+
...keyed("action", base.actions, after.actions, actionChanges("")),
|
|
94
|
+
...keyed("inbox", base.inboxes, after.inboxes),
|
|
95
|
+
...keyed("view", base.views, after.views),
|
|
96
|
+
...keyed("page", base.pages, after.pages),
|
|
97
|
+
...keyed("flag", base.flags, after.flags, (name, a, b) => a.approval !== b.approval ? [{
|
|
98
|
+
kind: "change",
|
|
99
|
+
text: `flag ${name} ${b.approval ? "now needs" : "no longer needs"} a second approver`
|
|
100
|
+
}] : []),
|
|
101
|
+
...keyed("notification", base.notify, after.notify),
|
|
102
|
+
...keyed("role", base.roles, after.roles, (name, a, b) => {
|
|
103
|
+
const role = `role ${name}`;
|
|
104
|
+
return [
|
|
105
|
+
...listDelta(role, ["gains", "loses"], a.actions, b.actions),
|
|
106
|
+
...listDelta(role, ["sees inbox", "no longer sees inbox"], a.inboxes, b.inboxes),
|
|
107
|
+
...listDelta(role, ["sees resource", "no longer sees resource"], a.resources, b.resources),
|
|
108
|
+
...listDelta(role, ["sees page", "no longer sees page"], a.pages, b.pages),
|
|
109
|
+
...listDelta(role, ["can change flag", "can no longer change flag"], a.flags, b.flags),
|
|
110
|
+
...a.status !== b.status ? [{
|
|
111
|
+
kind: b.status ? "grant" : "revoke",
|
|
112
|
+
text: `${role} ${b.status ? "can" : "can no longer"} post to the status page`
|
|
113
|
+
}] : [],
|
|
114
|
+
...a.reveal !== b.reveal ? [{
|
|
115
|
+
kind: b.reveal ? "grant" : "revoke",
|
|
116
|
+
text: `${role} ${b.reveal ? "can" : "can no longer"} reveal sensitive fields`
|
|
117
|
+
}] : []
|
|
118
|
+
];
|
|
119
|
+
})
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
function actionChanges(prefix) {
|
|
123
|
+
return (name, a, b) => {
|
|
124
|
+
const action = `action ${prefix}${name}`;
|
|
125
|
+
return [
|
|
126
|
+
...a.danger !== b.danger ? [{
|
|
127
|
+
kind: "change",
|
|
128
|
+
text: `${action} danger ${a.danger} → ${b.danger}`
|
|
129
|
+
}] : [],
|
|
130
|
+
...a.approval !== b.approval ? [{
|
|
131
|
+
kind: "change",
|
|
132
|
+
text: `${action} ${b.approval ? "now needs" : "no longer needs"} a second approver`
|
|
133
|
+
}] : [],
|
|
134
|
+
...a.schedule?.cron !== b.schedule?.cron ? [{
|
|
135
|
+
kind: "change",
|
|
136
|
+
text: b.schedule ? `${action} runs on "${b.schedule.cron}"` : `${action} no longer runs on a schedule`
|
|
137
|
+
}] : []
|
|
138
|
+
];
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region ../protocol/src/checks.ts
|
|
143
|
+
const COLUMNS = [
|
|
144
|
+
"title",
|
|
145
|
+
"state",
|
|
146
|
+
"occurrences",
|
|
147
|
+
"users",
|
|
148
|
+
"lastSeen",
|
|
149
|
+
"firstSeen",
|
|
150
|
+
"assignee",
|
|
151
|
+
"tags",
|
|
152
|
+
"due"
|
|
153
|
+
];
|
|
154
|
+
function checkColumns(source, inbox, columns, problems) {
|
|
155
|
+
for (const column of columns) {
|
|
156
|
+
if (COLUMNS.includes(column)) continue;
|
|
157
|
+
const field = column.startsWith("data.") ? column.slice(5) : null;
|
|
158
|
+
if (field && inbox.data?.properties?.[field]) continue;
|
|
159
|
+
problems.push({
|
|
160
|
+
source,
|
|
161
|
+
message: `column "${column}" doesn't exist on inbox data`
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function checkManifest(manifest) {
|
|
166
|
+
const problems = [];
|
|
167
|
+
const actionExists = (id) => id.endsWith(".*") ? Boolean(manifest.resources[id.slice(0, -2)]) : Boolean(findActionDef(manifest, id));
|
|
168
|
+
for (const [name, action] of Object.entries(manifest.actions)) {
|
|
169
|
+
if (name.includes(".") || name.startsWith("flag:")) problems.push({
|
|
170
|
+
source: action.source,
|
|
171
|
+
message: `action "${name}" can't contain "." or start with "flag:"`
|
|
172
|
+
});
|
|
173
|
+
if (action.schedule) checkSchedule(name, action, problems);
|
|
174
|
+
}
|
|
175
|
+
for (const [name, page] of Object.entries(manifest.pages)) if (page.blocks) checkBlocks(manifest, page.source, `page ${name}`, page.blocks, problems);
|
|
176
|
+
if (manifest.status) checkStatus(manifest, problems);
|
|
177
|
+
for (const [name, flag] of Object.entries(manifest.flags)) if (flag.target && !manifest.resources[flag.target]) problems.push({
|
|
178
|
+
source: flag.source,
|
|
179
|
+
message: `flag ${name} targets unknown resource "${flag.target}"`
|
|
180
|
+
});
|
|
181
|
+
for (const [name, resource] of Object.entries(manifest.resources)) {
|
|
182
|
+
for (const [relation, def] of Object.entries(resource.related)) if (!manifest.resources[def.resource]) problems.push({
|
|
183
|
+
source: resource.source,
|
|
184
|
+
message: `${name}.${relation} points at unknown resource "${def.resource}"`
|
|
185
|
+
});
|
|
186
|
+
for (const [key, action] of Object.entries(resource.actions)) {
|
|
187
|
+
const inputs = action.input?.properties ?? {};
|
|
188
|
+
for (const field of [...Object.keys(action.pick), ...action.choices]) if (!inputs[field]) problems.push({
|
|
189
|
+
source: action.source,
|
|
190
|
+
message: `${name}.${key} has no input field "${field}"`
|
|
191
|
+
});
|
|
192
|
+
for (const [field, target] of Object.entries(action.pick)) if (!manifest.resources[target]) problems.push({
|
|
193
|
+
source: action.source,
|
|
194
|
+
message: `${name}.${key} picks ${field} from unknown resource "${target}"`
|
|
195
|
+
});
|
|
196
|
+
else if (!manifest.resources[target].searchable) problems.push({
|
|
197
|
+
source: action.source,
|
|
198
|
+
message: `${name}.${key} picks ${field} from ${target}, which needs a find function to be searchable`
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const role of Object.values(manifest.roles)) for (const id of role.actions) if (!actionExists(id)) problems.push({
|
|
203
|
+
source: role.source,
|
|
204
|
+
message: `grants unknown action ${id}`
|
|
205
|
+
});
|
|
206
|
+
for (const inbox of Object.values(manifest.inboxes)) {
|
|
207
|
+
checkColumns(inbox.source, inbox, inbox.columns, problems);
|
|
208
|
+
for (const state of inbox.reopen) if (!inbox.states.includes(state)) problems.push({
|
|
209
|
+
source: inbox.source,
|
|
210
|
+
message: `reopens from unknown state "${state}"`
|
|
211
|
+
});
|
|
212
|
+
for (const rule of inbox.triage?.rules ?? []) for (const decision of rule.then) {
|
|
213
|
+
if (decision.type === "state" && !inbox.states.includes(decision.state)) problems.push({
|
|
214
|
+
source: inbox.source,
|
|
215
|
+
message: `${rule.name} moves items to unknown state "${decision.state}"`
|
|
216
|
+
});
|
|
217
|
+
if (decision.type !== "run") continue;
|
|
218
|
+
const def = findActionDef(manifest, decision.action);
|
|
219
|
+
if (!def) problems.push({
|
|
220
|
+
source: inbox.source,
|
|
221
|
+
message: `triage runs unknown action ${decision.action}`
|
|
222
|
+
});
|
|
223
|
+
else if (def.approval) problems.push({
|
|
224
|
+
source: inbox.source,
|
|
225
|
+
message: `triage runs ${decision.action}, which needs a second approver, so triage can't run it`
|
|
226
|
+
});
|
|
227
|
+
else if (def.danger !== "none" && !def.automatable) problems.push({
|
|
228
|
+
source: inbox.source,
|
|
229
|
+
message: `triage runs ${decision.action}, which needs a human (danger "${def.danger}"). Set automatable: true on the action to allow it.`
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const view of Object.values(manifest.views)) {
|
|
234
|
+
const inbox = manifest.inboxes[view.inbox];
|
|
235
|
+
checkColumns(view.source, inbox, view.columns, problems);
|
|
236
|
+
for (const state of view.filter.state ?? []) if (!inbox.states.includes(state)) problems.push({
|
|
237
|
+
source: view.source,
|
|
238
|
+
message: `filters on unknown state "${state}"`
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return problems;
|
|
242
|
+
}
|
|
243
|
+
function checkSchedule(name, action, problems) {
|
|
244
|
+
const { cron, timezone } = action.schedule;
|
|
245
|
+
try {
|
|
246
|
+
new Cron(cron, {
|
|
247
|
+
timezone: timezone ?? void 0,
|
|
248
|
+
paused: true
|
|
249
|
+
});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
problems.push({
|
|
252
|
+
source: action.source,
|
|
253
|
+
message: `${name} has an invalid schedule: ${error.message}`
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (action.input?.required?.length) problems.push({
|
|
257
|
+
source: action.source,
|
|
258
|
+
message: `${name} runs on a schedule, so ${action.input.required.join(", ")} need defaults in its input`
|
|
259
|
+
});
|
|
260
|
+
if (action.approval) problems.push({
|
|
261
|
+
source: action.source,
|
|
262
|
+
message: `${name} runs on a schedule, so it can't need a second approver`
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const COMPONENT = /^[a-z0-9][a-z0-9-]*$/;
|
|
266
|
+
const WEB_ADDRESS = /^https?:\/\/[^\s/$.?#].\S*$/;
|
|
267
|
+
const CSS_LIMIT = 5e4;
|
|
268
|
+
function checkStatus(manifest, problems) {
|
|
269
|
+
const status = manifest.status;
|
|
270
|
+
const problem = (message) => problems.push({
|
|
271
|
+
source: status.source,
|
|
272
|
+
message: `status page ${message}`
|
|
273
|
+
});
|
|
274
|
+
const components = Object.entries(status.components);
|
|
275
|
+
if (!components.length) problem("needs at least one component");
|
|
276
|
+
for (const [name, component] of components) {
|
|
277
|
+
if (!COMPONENT.test(name)) problem(`component "${name}" should be lowercase letters, numbers and dashes`);
|
|
278
|
+
if (component.monitored && component.check) problem(`component ${name} can follow your app or a check, not both`);
|
|
279
|
+
if (component.check && !WEB_ADDRESS.test(component.check)) problem(`component ${name} checks "${component.check}", which isn't a web address`);
|
|
280
|
+
}
|
|
281
|
+
for (const recipient of status.alert) if ("role" in recipient && !manifest.roles[recipient.role]) problem(`alerts role "${recipient.role}", which doesn't exist`);
|
|
282
|
+
if (status.after < 6e4) problem("waits less than a minute before calling your app down. Use 1m or more.");
|
|
283
|
+
for (const [what, url] of [
|
|
284
|
+
["logo", status.logo?.light],
|
|
285
|
+
["dark logo", status.logo?.dark],
|
|
286
|
+
["website", status.website]
|
|
287
|
+
]) if (url && !WEB_ADDRESS.test(url)) problem(`${what} "${url}" isn't a web address`);
|
|
288
|
+
if (status.css && status.css.length > CSS_LIMIT) problem(`css is ${Math.ceil(status.css.length / 1e3)} KB. Keep it under ${CSS_LIMIT / 1e3} KB.`);
|
|
289
|
+
}
|
|
290
|
+
function checkBlocks(manifest, source, owner, blocks, problems) {
|
|
291
|
+
const problem = (message) => problems.push({
|
|
292
|
+
source,
|
|
293
|
+
message: `${owner} ${message}`
|
|
294
|
+
});
|
|
295
|
+
const action = (id) => {
|
|
296
|
+
if (!findActionDef(manifest, id)) problem(`uses unknown action ${id}`);
|
|
297
|
+
};
|
|
298
|
+
eachBlock(blocks, (block) => {
|
|
299
|
+
if (block.type === "actions") for (const button of block.actions) action(button.action);
|
|
300
|
+
if (block.type === "form") action(block.form.action);
|
|
301
|
+
if (block.type === "flag" && !manifest.flags[block.flag]) problem(`shows unknown flag ${block.flag}`);
|
|
302
|
+
if ((block.type === "count" || block.type === "items" || block.type === "chart") && "inbox" in block) {
|
|
303
|
+
if (!manifest.inboxes[block.inbox]) problem(`shows unknown inbox ${block.inbox}`);
|
|
304
|
+
}
|
|
305
|
+
if (block.type === "fields") {
|
|
306
|
+
for (const { value } of block.fields) if (value && typeof value === "object" && "edit" in value) action(value.edit.action);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/build/check.ts
|
|
312
|
+
function check(manifest, impurities) {
|
|
313
|
+
return [...impurities.map(({ file, effect }) => ({
|
|
314
|
+
source: file,
|
|
315
|
+
message: `has a top-level side effect: ${effect}. Move it inside a handler or import it lazily.`
|
|
316
|
+
})), ...checkManifest(manifest)];
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/build/load.ts
|
|
320
|
+
const CONFIG_FILES = [
|
|
321
|
+
"pult.config.ts",
|
|
322
|
+
"pult.config.mts",
|
|
323
|
+
"pult.config.js",
|
|
324
|
+
"pult.config.mjs"
|
|
325
|
+
];
|
|
326
|
+
function findConfig(root) {
|
|
327
|
+
const file = CONFIG_FILES.map((name) => join(root, name)).find(existsSync);
|
|
328
|
+
if (!file) throw new Error(`No pult.config.ts in ${root}`);
|
|
329
|
+
return file;
|
|
330
|
+
}
|
|
331
|
+
async function loadProject(root, importer) {
|
|
332
|
+
const config = (await importer(findConfig(root))).default;
|
|
333
|
+
if (kindOf(config) !== "config") throw new Error("pult.config.ts must default-export defineConfig({ ... })");
|
|
334
|
+
const { include } = config.options;
|
|
335
|
+
const files = /* @__PURE__ */ new Set();
|
|
336
|
+
for (const pattern of include) for await (const file of glob(pattern, {
|
|
337
|
+
cwd: root,
|
|
338
|
+
exclude: (name) => name.includes("node_modules")
|
|
339
|
+
})) files.add(relative(root, join(root, file)));
|
|
340
|
+
const impurities = [];
|
|
341
|
+
const modules = [];
|
|
342
|
+
for (const file of [...files].sort()) {
|
|
343
|
+
const restore = trap((effect) => impurities.push({
|
|
344
|
+
file,
|
|
345
|
+
effect
|
|
346
|
+
}));
|
|
347
|
+
try {
|
|
348
|
+
modules.push(await importer(join(root, file)));
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (!impurities.some((impurity) => impurity.file === file)) throw error;
|
|
351
|
+
} finally {
|
|
352
|
+
restore();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
root,
|
|
357
|
+
config,
|
|
358
|
+
files: [...files].sort(),
|
|
359
|
+
modules,
|
|
360
|
+
impurities
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function trap(report) {
|
|
364
|
+
const original = {
|
|
365
|
+
fetch: globalThis.fetch,
|
|
366
|
+
WebSocket: globalThis.WebSocket,
|
|
367
|
+
setInterval: globalThis.setInterval,
|
|
368
|
+
connect: net.Socket.prototype.connect
|
|
369
|
+
};
|
|
370
|
+
const refuse = (effect) => {
|
|
371
|
+
report(effect);
|
|
372
|
+
return /* @__PURE__ */ new Error(`Definition files must not ${effect} when imported`);
|
|
373
|
+
};
|
|
374
|
+
globalThis.fetch = Object.assign((input) => Promise.reject(refuse(`fetch ${input instanceof Request ? input.url : input}`)), original.fetch);
|
|
375
|
+
globalThis.WebSocket = class {
|
|
376
|
+
constructor(url) {
|
|
377
|
+
throw refuse(`open a WebSocket to ${url}`);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
globalThis.setInterval = ((..._) => {
|
|
381
|
+
throw refuse("start an interval");
|
|
382
|
+
});
|
|
383
|
+
net.Socket.prototype.connect = function() {
|
|
384
|
+
throw refuse("open a network connection");
|
|
385
|
+
};
|
|
386
|
+
return () => {
|
|
387
|
+
globalThis.fetch = original.fetch;
|
|
388
|
+
globalThis.WebSocket = original.WebSocket;
|
|
389
|
+
globalThis.setInterval = original.setInterval;
|
|
390
|
+
net.Socket.prototype.connect = original.connect;
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/build/manifest.ts
|
|
395
|
+
const sources = /* @__PURE__ */ new Map();
|
|
396
|
+
function lines(file) {
|
|
397
|
+
let cached = sources.get(file);
|
|
398
|
+
if (!cached) {
|
|
399
|
+
cached = readFileSync(file, "utf8").split("\n");
|
|
400
|
+
sources.set(file, cached);
|
|
401
|
+
}
|
|
402
|
+
return cached;
|
|
403
|
+
}
|
|
404
|
+
function escapeRegExp(text) {
|
|
405
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
406
|
+
}
|
|
407
|
+
var Locator = class {
|
|
408
|
+
root;
|
|
409
|
+
constructor(root) {
|
|
410
|
+
this.root = root;
|
|
411
|
+
}
|
|
412
|
+
locate(origin, pattern, after = 0) {
|
|
413
|
+
const file = relative(this.root, origin.file);
|
|
414
|
+
if (!pattern || !origin.file) return `${file}:${origin.line}`;
|
|
415
|
+
const index = lines(origin.file).findIndex((line, i) => i >= after && pattern.test(line));
|
|
416
|
+
return `${file}:${index === -1 ? origin.line : index + 1}`;
|
|
417
|
+
}
|
|
418
|
+
call(kind, origin, name) {
|
|
419
|
+
return this.locate(origin, new RegExp(`\\b${kind}\\(\\s*["'\`]${escapeRegExp(name)}["'\`]`));
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
function humanize(name) {
|
|
423
|
+
const spaced = name.replace(/[-_]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
424
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
425
|
+
}
|
|
426
|
+
function pluralize(label) {
|
|
427
|
+
return /(s|x|sh|ch)$/.test(label) ? `${label}es` : /[^aeiou]y$/.test(label) ? `${label.slice(0, -1)}ies` : `${label}s`;
|
|
428
|
+
}
|
|
429
|
+
function actionDef(source, key, action) {
|
|
430
|
+
const { options } = action;
|
|
431
|
+
return {
|
|
432
|
+
source,
|
|
433
|
+
label: options.label ?? humanize(key),
|
|
434
|
+
description: options.description,
|
|
435
|
+
danger: options.danger ?? "none",
|
|
436
|
+
input: options.input ? toJsonSchema(options.input) : null,
|
|
437
|
+
preview: Boolean(options.preview),
|
|
438
|
+
automatable: options.automatable ?? false,
|
|
439
|
+
approval: options.approval ?? false,
|
|
440
|
+
contextual: Boolean(options.when || options.defaults),
|
|
441
|
+
background: options.background ?? false,
|
|
442
|
+
pick: options.pick ?? {},
|
|
443
|
+
choices: Object.keys(options.choices ?? {}),
|
|
444
|
+
schedule: options.schedule ? schedule(options.schedule) : null
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
function schedule(input) {
|
|
448
|
+
return typeof input === "string" ? {
|
|
449
|
+
cron: input,
|
|
450
|
+
timezone: null
|
|
451
|
+
} : {
|
|
452
|
+
cron: input.cron,
|
|
453
|
+
timezone: input.timezone ?? null
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function resourceDef(locator, resource) {
|
|
457
|
+
const source = locator.call("resource", resource.origin, resource.name);
|
|
458
|
+
const line = Number(source.split(":").pop()) - 1;
|
|
459
|
+
const label = resource.options.label ?? humanize(resource.name);
|
|
460
|
+
const actions = {};
|
|
461
|
+
for (const [key, action] of Object.entries(resource.actions)) {
|
|
462
|
+
if (kindOf(action) !== "action") continue;
|
|
463
|
+
const keyPattern = new RegExp(`(^|[\\s{,"'])${escapeRegExp(key)}["']?\\s*:\\s*action\\(`);
|
|
464
|
+
actions[key] = actionDef(locator.locate(resource.origin, keyPattern, line), key, action);
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
source,
|
|
468
|
+
label,
|
|
469
|
+
plural: resource.options.plural ?? pluralize(label),
|
|
470
|
+
icon: resource.options.icon ?? "box",
|
|
471
|
+
searchable: Boolean(resource.options.find),
|
|
472
|
+
list: resource.options.list ? { filters: resource.options.list.filters ? toJsonSchema(resource.options.list.filters) : null } : null,
|
|
473
|
+
related: Object.fromEntries(Object.entries(resource.options.related ?? {}).map(([name, relation]) => [name, {
|
|
474
|
+
resource: relation.resource,
|
|
475
|
+
label: relation.label ?? humanize(name)
|
|
476
|
+
}])),
|
|
477
|
+
actions
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function inboxDef(locator, inbox) {
|
|
481
|
+
const { options } = inbox;
|
|
482
|
+
const intake = options.intake === true ? {} : options.intake || null;
|
|
483
|
+
const label = options.label ?? humanize(inbox.name);
|
|
484
|
+
return {
|
|
485
|
+
source: locator.call("inbox", inbox.origin, inbox.name),
|
|
486
|
+
label,
|
|
487
|
+
icon: options.icon ?? "inbox",
|
|
488
|
+
resource: options.resource?.name ?? null,
|
|
489
|
+
layout: options.layout ?? "list",
|
|
490
|
+
states: inbox.states,
|
|
491
|
+
closed: inbox.closed,
|
|
492
|
+
reopen: options.reopenOn ? options.reopenFrom ?? inbox.closed : [],
|
|
493
|
+
data: options.data ? toJsonSchema(options.data) : null,
|
|
494
|
+
columns: options.columns ?? defaultColumns(options.layout ?? "list"),
|
|
495
|
+
intake: intake && {
|
|
496
|
+
title: intake.title ?? label,
|
|
497
|
+
description: intake.description
|
|
498
|
+
},
|
|
499
|
+
triage: options.triage ? {
|
|
500
|
+
instructions: options.triage.instructions,
|
|
501
|
+
questions: options.triage.questions,
|
|
502
|
+
rules: options.triage.rules.map((rule, index) => ({
|
|
503
|
+
name: rule.name ?? `Rule ${index + 1}`,
|
|
504
|
+
live: rule.live ?? false,
|
|
505
|
+
then: [rule.then].flat()
|
|
506
|
+
}))
|
|
507
|
+
} : null,
|
|
508
|
+
reply: Boolean(options.reply),
|
|
509
|
+
hooks: { state: Boolean(options.onState) },
|
|
510
|
+
sla: Object.fromEntries(Object.entries(options.sla ?? {}).map(([state, duration]) => [state, milliseconds(duration)]))
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const UNITS = {
|
|
514
|
+
m: 6e4,
|
|
515
|
+
h: 36e5,
|
|
516
|
+
d: 864e5
|
|
517
|
+
};
|
|
518
|
+
function milliseconds(duration) {
|
|
519
|
+
const match = /^(\d+(?:\.\d+)?)([mhd])$/.exec(duration);
|
|
520
|
+
if (!match) throw new Error(`"${duration}" isn't a duration. Use something like "30m", "4h" or "2d".`);
|
|
521
|
+
return Number(match[1]) * UNITS[match[2]];
|
|
522
|
+
}
|
|
523
|
+
function defaultColumns(layout) {
|
|
524
|
+
return layout === "table" ? [
|
|
525
|
+
"title",
|
|
526
|
+
"occurrences",
|
|
527
|
+
"users",
|
|
528
|
+
"lastSeen"
|
|
529
|
+
] : ["title", "lastSeen"];
|
|
530
|
+
}
|
|
531
|
+
function viewDef(locator, view) {
|
|
532
|
+
const { options } = view;
|
|
533
|
+
return {
|
|
534
|
+
source: locator.call("view", view.origin, view.name),
|
|
535
|
+
label: options.label ?? humanize(view.name),
|
|
536
|
+
inbox: options.inbox.name,
|
|
537
|
+
filter: normalizeFilter(options.filter),
|
|
538
|
+
layout: options.layout ?? options.inbox.options.layout ?? "list",
|
|
539
|
+
columns: options.columns ?? options.inbox.options.columns ?? defaultColumns(options.layout ?? "list"),
|
|
540
|
+
sort: options.sort ?? "-lastSeen"
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function pageDef(locator, page) {
|
|
544
|
+
const { options } = page;
|
|
545
|
+
return {
|
|
546
|
+
source: page.name === "home" ? locator.locate(page.origin) : locator.call("page", page.origin, page.name),
|
|
547
|
+
label: options.label ?? humanize(page.name),
|
|
548
|
+
icon: options.icon ?? "layout",
|
|
549
|
+
params: options.params ? toJsonSchema(options.params) : null,
|
|
550
|
+
blocks: typeof options.render === "function" ? null : staticBlocks(page.name, options.render)
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
function staticBlocks(page, blocks) {
|
|
554
|
+
if (blocks.some(isPendingAttachment)) throw new Error(`Page "${page}" attaches file data. Render it from a function instead.`);
|
|
555
|
+
return JSON.parse(JSON.stringify(blocks));
|
|
556
|
+
}
|
|
557
|
+
function flagDef(locator, flag) {
|
|
558
|
+
const { options } = flag;
|
|
559
|
+
return {
|
|
560
|
+
source: locator.call("flag", flag.origin, flag.name),
|
|
561
|
+
label: options.label ?? humanize(flag.name),
|
|
562
|
+
description: options.description,
|
|
563
|
+
value: options.value ? toJsonSchema(options.value) : { type: "boolean" },
|
|
564
|
+
default: JSON.parse(JSON.stringify(options.default ?? null)),
|
|
565
|
+
target: options.target?.name ?? null,
|
|
566
|
+
attributes: options.attributes ? toJsonSchema(options.attributes) : null,
|
|
567
|
+
guards: guards(flag).map((guard) => ({
|
|
568
|
+
inbox: guard.inbox.name,
|
|
569
|
+
perHour: guard.perHour,
|
|
570
|
+
then: guard.then ?? "pause"
|
|
571
|
+
})),
|
|
572
|
+
danger: options.danger ?? "none",
|
|
573
|
+
approval: options.approval ?? false
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function notifyDef(locator, rule) {
|
|
577
|
+
return {
|
|
578
|
+
source: locator.call("notify", rule.origin, rule.name),
|
|
579
|
+
inboxes: rule.options.inboxes.map((inbox) => inbox.name),
|
|
580
|
+
on: rule.options.on,
|
|
581
|
+
to: rule.options.to.map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name })
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function logo(value) {
|
|
585
|
+
if (value === void 0) return null;
|
|
586
|
+
return typeof value === "string" ? {
|
|
587
|
+
light: value,
|
|
588
|
+
dark: value
|
|
589
|
+
} : value;
|
|
590
|
+
}
|
|
591
|
+
function statusDef(locator, page, config) {
|
|
592
|
+
return {
|
|
593
|
+
source: locator.locate(page.origin, /\bstatus\(/),
|
|
594
|
+
title: page.options.title ?? humanize(config.options.project),
|
|
595
|
+
logo: logo(page.options.logo),
|
|
596
|
+
website: page.options.website ?? null,
|
|
597
|
+
css: page.options.css?.trim() || null,
|
|
598
|
+
components: Object.fromEntries(Object.entries(page.options.components).map(([name, component]) => [name, typeof component === "string" ? {
|
|
599
|
+
label: component,
|
|
600
|
+
description: null,
|
|
601
|
+
monitored: false,
|
|
602
|
+
check: null
|
|
603
|
+
} : {
|
|
604
|
+
label: component.label,
|
|
605
|
+
description: component.description ?? null,
|
|
606
|
+
monitored: component.monitored ?? false,
|
|
607
|
+
check: component.check ?? null
|
|
608
|
+
}])),
|
|
609
|
+
alert: (page.options.alert ?? []).map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name }),
|
|
610
|
+
after: milliseconds(page.options.after ?? "2m")
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function assemble(root, definitions, config) {
|
|
614
|
+
const locator = new Locator(root);
|
|
615
|
+
const map = (source, convert) => Object.fromEntries([...source].sort(([a], [b]) => a.localeCompare(b)).map(([name, value]) => [name, convert(value)]));
|
|
616
|
+
const roles = map(definitions.roles, (role) => ({
|
|
617
|
+
source: locator.call("role", role.origin, role.name),
|
|
618
|
+
label: role.options.label ?? humanize(role.name),
|
|
619
|
+
inboxes: (role.options.inboxes ?? []).map((inbox) => inbox.name),
|
|
620
|
+
resources: (role.options.resources ?? []).map((resource) => resource.name),
|
|
621
|
+
actions: (role.options.actions ?? []).map((action) => action.id),
|
|
622
|
+
pages: (role.options.pages ?? []).map((page) => page.name),
|
|
623
|
+
flags: (role.options.flags ?? []).map((flag) => flag.name),
|
|
624
|
+
reveal: role.options.reveal ?? false,
|
|
625
|
+
status: role.options.status ?? false
|
|
626
|
+
}));
|
|
627
|
+
const manifest = {
|
|
628
|
+
project: config.options.project,
|
|
629
|
+
environments: config.options.environments,
|
|
630
|
+
sync: config.options.sync,
|
|
631
|
+
delivery: config.options.delivery,
|
|
632
|
+
resources: map(definitions.resources, (resource) => resourceDef(locator, resource)),
|
|
633
|
+
actions: map(definitions.actions, (action) => actionDef(locator.call("action", action.origin, action.name), action.name, action)),
|
|
634
|
+
inboxes: map(definitions.inboxes, (inbox) => inboxDef(locator, inbox)),
|
|
635
|
+
views: map(definitions.views, (view) => viewDef(locator, view)),
|
|
636
|
+
pages: map(definitions.pages, (page) => pageDef(locator, page)),
|
|
637
|
+
flags: map(definitions.flags, (flag) => flagDef(locator, flag)),
|
|
638
|
+
roles,
|
|
639
|
+
notify: map(definitions.notify, (rule) => notifyDef(locator, rule)),
|
|
640
|
+
status: definitions.status && statusDef(locator, definitions.status, config)
|
|
641
|
+
};
|
|
642
|
+
return {
|
|
643
|
+
...manifest,
|
|
644
|
+
version: hash(stableJson(manifest))
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function hash(text) {
|
|
648
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 12);
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/build/codegen.ts
|
|
652
|
+
const OUT_DIR = ".pult";
|
|
653
|
+
function projectModule(files) {
|
|
654
|
+
return [
|
|
655
|
+
"// Generated by `pult build`. Commit this file; don't edit it.",
|
|
656
|
+
"import type { Manifest, Project } from \"pult\"",
|
|
657
|
+
"import manifest from \"./manifest.json\" with { type: \"json\" }",
|
|
658
|
+
"",
|
|
659
|
+
"export default {",
|
|
660
|
+
" manifest: manifest as Manifest,",
|
|
661
|
+
" modules: {",
|
|
662
|
+
...files.map((file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(`../${file}`)}),`),
|
|
663
|
+
" },",
|
|
664
|
+
"} satisfies Project",
|
|
665
|
+
""
|
|
666
|
+
].join("\n");
|
|
667
|
+
}
|
|
668
|
+
async function readManifest(root) {
|
|
669
|
+
try {
|
|
670
|
+
return JSON.parse(await readFile(join(root, OUT_DIR, "manifest.json"), "utf8"));
|
|
671
|
+
} catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async function writeOutput(root, manifest, files) {
|
|
676
|
+
const dir = join(root, OUT_DIR);
|
|
677
|
+
await mkdir(dir, { recursive: true });
|
|
678
|
+
await writeFile(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
679
|
+
await writeFile(join(dir, "index.ts"), projectModule(files));
|
|
680
|
+
}
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/build/index.ts
|
|
683
|
+
async function build(root, importer) {
|
|
684
|
+
const project = await loadProject(root, importer);
|
|
685
|
+
const definitions = collect(project.modules);
|
|
686
|
+
const manifest = assemble(root, definitions, project.config);
|
|
687
|
+
const problems = [...check(manifest, project.impurities), ...await checkDefaults(definitions, manifest)];
|
|
688
|
+
return {
|
|
689
|
+
manifest,
|
|
690
|
+
files: project.files,
|
|
691
|
+
problems
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
async function checkDefaults(definitions, manifest) {
|
|
695
|
+
const problems = [];
|
|
696
|
+
for (const [name, flag] of definitions.flags) {
|
|
697
|
+
if (!flag.options.value) continue;
|
|
698
|
+
await validate(flag.options.value, flag.options.default).catch((error) => problems.push({
|
|
699
|
+
source: manifest.flags[name].source,
|
|
700
|
+
message: `flag ${name} defaults to an invalid value: ${error.message}`
|
|
701
|
+
}));
|
|
702
|
+
}
|
|
703
|
+
return problems;
|
|
704
|
+
}
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/cli/output.ts
|
|
707
|
+
const dim = (text) => styleText("dim", text);
|
|
708
|
+
const bold = (text) => styleText("bold", text);
|
|
709
|
+
function fail(message) {
|
|
710
|
+
console.error(`${styleText("red", "✕")} ${message}`);
|
|
711
|
+
process.exit(1);
|
|
712
|
+
}
|
|
713
|
+
function done(message) {
|
|
714
|
+
console.log(`${styleText("green", "✓")} ${message}`);
|
|
715
|
+
}
|
|
716
|
+
function printProblems(problems) {
|
|
717
|
+
for (const { source, message } of problems) console.error(`${styleText("red", "✕")} ${dim(source)} ${message}`);
|
|
718
|
+
}
|
|
719
|
+
const MARK = {
|
|
720
|
+
add: ["+", "green"],
|
|
721
|
+
remove: ["-", "red"],
|
|
722
|
+
change: ["~", "yellow"],
|
|
723
|
+
grant: ["▲", ["bold", "magenta"]],
|
|
724
|
+
revoke: ["▼", "magenta"]
|
|
725
|
+
};
|
|
726
|
+
function printChanges(changes) {
|
|
727
|
+
if (!changes.length) return console.log(dim(" no changes"));
|
|
728
|
+
for (const change of changes) {
|
|
729
|
+
const [mark, style] = MARK[change.kind];
|
|
730
|
+
console.log(` ${styleText(style, mark)} ${change.text}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
//#endregion
|
|
734
|
+
//#region src/cli/project.ts
|
|
735
|
+
const RUNTIME_MODULES = [
|
|
736
|
+
"cloudflare:workers",
|
|
737
|
+
"cloudflare:email",
|
|
738
|
+
"cloudflare:sockets"
|
|
739
|
+
];
|
|
740
|
+
const STUB = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "../build/stub.ts" : "./stub.mjs", import.meta.url));
|
|
741
|
+
const importer = await (async () => {
|
|
742
|
+
if (typeof Bun !== "undefined") {
|
|
743
|
+
const exports = await import(STUB);
|
|
744
|
+
Bun.plugin({
|
|
745
|
+
name: "cloudflare-runtime",
|
|
746
|
+
setup(build) {
|
|
747
|
+
for (const name of RUNTIME_MODULES) build.module(name, () => ({
|
|
748
|
+
exports,
|
|
749
|
+
loader: "object"
|
|
750
|
+
}));
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
return (file) => import(file);
|
|
754
|
+
}
|
|
755
|
+
const { createJiti } = await import("jiti");
|
|
756
|
+
const alias = Object.fromEntries(RUNTIME_MODULES.map((name) => [name, STUB]));
|
|
757
|
+
const jiti = createJiti(import.meta.url, { alias });
|
|
758
|
+
return (file) => jiti.import(file);
|
|
759
|
+
})();
|
|
760
|
+
async function buildProject(root) {
|
|
761
|
+
try {
|
|
762
|
+
return await build(root, importer);
|
|
763
|
+
} catch (error) {
|
|
764
|
+
fail(error.message);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
var Remote = class {
|
|
768
|
+
environment;
|
|
769
|
+
#secret;
|
|
770
|
+
#url;
|
|
771
|
+
constructor() {
|
|
772
|
+
const key = process.env.PULT_KEY;
|
|
773
|
+
if (!key) fail("Set PULT_KEY to the key of the environment you're targeting");
|
|
774
|
+
const parsed = parseKey(key);
|
|
775
|
+
this.environment = parsed.environment;
|
|
776
|
+
this.#secret = parsed.secret;
|
|
777
|
+
this.#url = (process.env.PULT_URL ?? "https://app.pult.sh").replace(/\/$/, "");
|
|
778
|
+
}
|
|
779
|
+
async #request(method, path, body) {
|
|
780
|
+
const payload = body === void 0 ? "" : JSON.stringify(body);
|
|
781
|
+
const signature = await sign(this.#secret, `${method} ${path}`, payload);
|
|
782
|
+
const response = await fetch(`${this.#url}${path}`, {
|
|
783
|
+
method,
|
|
784
|
+
body: method === "POST" ? payload : void 0,
|
|
785
|
+
headers: {
|
|
786
|
+
"content-type": "application/json",
|
|
787
|
+
[HEADER.environment]: this.environment,
|
|
788
|
+
[HEADER.timestamp]: signature.timestamp,
|
|
789
|
+
[HEADER.nonce]: signature.nonce,
|
|
790
|
+
[HEADER.signature]: signature.signature
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
const json = await response.json().catch(() => null);
|
|
794
|
+
if (!response.ok) fail(json?.error ?? `${response.status} ${response.statusText}`);
|
|
795
|
+
return json;
|
|
796
|
+
}
|
|
797
|
+
current() {
|
|
798
|
+
return this.#request("GET", "/v1/manifest");
|
|
799
|
+
}
|
|
800
|
+
deploy(manifest) {
|
|
801
|
+
return this.#request("POST", "/v1/manifest", manifest);
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
//#endregion
|
|
805
|
+
//#region src/cli/commands/build.ts
|
|
806
|
+
async function buildCommand(root) {
|
|
807
|
+
const { manifest, files, problems } = await buildProject(root);
|
|
808
|
+
if (problems.length) {
|
|
809
|
+
printProblems(problems);
|
|
810
|
+
fail(`${problems.length} problem${problems.length === 1 ? "" : "s"}, manifest not written`);
|
|
811
|
+
}
|
|
812
|
+
await writeOutput(root, manifest, files);
|
|
813
|
+
done(`manifest ${manifest.version} · ${files.length} files`);
|
|
814
|
+
return manifest;
|
|
815
|
+
}
|
|
816
|
+
//#endregion
|
|
817
|
+
//#region src/cli/commands/check.ts
|
|
818
|
+
async function checkCommand(root) {
|
|
819
|
+
const { manifest, problems } = await buildProject(root);
|
|
820
|
+
printProblems(problems);
|
|
821
|
+
if (problems.length) fail(`${problems.length} problem${problems.length === 1 ? "" : "s"}`);
|
|
822
|
+
if ((await readManifest(root))?.version !== manifest.version) fail("The committed manifest is out of date. Run `pult build`.");
|
|
823
|
+
done(`manifest ${manifest.version} is valid and up to date`);
|
|
824
|
+
}
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/cli/commands/deploy.ts
|
|
827
|
+
async function deployCommand(root) {
|
|
828
|
+
const manifest = await buildCommand(root);
|
|
829
|
+
const remote = new Remote();
|
|
830
|
+
const current = await remote.current();
|
|
831
|
+
if (current.manifest?.version === manifest.version) return done(`${current.name} is already on ${manifest.version}`);
|
|
832
|
+
console.log(`${bold(current.name)} ${dim(`${current.manifest?.version ?? "empty"} → ${manifest.version}`)}`);
|
|
833
|
+
printChanges(diffManifests(current.manifest, manifest));
|
|
834
|
+
await remote.deploy(manifest);
|
|
835
|
+
done(`deployed ${manifest.version} to ${current.name}`);
|
|
836
|
+
}
|
|
837
|
+
//#endregion
|
|
838
|
+
//#region src/cli/commands/dev.ts
|
|
839
|
+
const IGNORED = /(^|[\\/])(node_modules|\.pult|\.git|dist|build)([\\/]|$)/;
|
|
840
|
+
function devCommand(root) {
|
|
841
|
+
let running = false;
|
|
842
|
+
let queued = false;
|
|
843
|
+
const rebuild = () => {
|
|
844
|
+
if (running) {
|
|
845
|
+
queued = true;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
running = true;
|
|
849
|
+
spawn(process.execPath, [
|
|
850
|
+
...process.execArgv,
|
|
851
|
+
process.argv[1],
|
|
852
|
+
"build"
|
|
853
|
+
], {
|
|
854
|
+
cwd: root,
|
|
855
|
+
stdio: "inherit"
|
|
856
|
+
}).on("exit", () => {
|
|
857
|
+
running = false;
|
|
858
|
+
if (queued) {
|
|
859
|
+
queued = false;
|
|
860
|
+
rebuild();
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
};
|
|
864
|
+
let timer;
|
|
865
|
+
watch(root, { recursive: true }, (_, file) => {
|
|
866
|
+
if (!file || IGNORED.test(file) || !/\.(m?[jt]s)$/.test(file)) return;
|
|
867
|
+
clearTimeout(timer);
|
|
868
|
+
timer = setTimeout(rebuild, 80);
|
|
869
|
+
});
|
|
870
|
+
console.log(dim("watching for changes"));
|
|
871
|
+
rebuild();
|
|
872
|
+
}
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region src/cli/commands/diff.ts
|
|
875
|
+
async function diffCommand(root) {
|
|
876
|
+
const { manifest, problems } = await buildProject(root);
|
|
877
|
+
if (problems.length) {
|
|
878
|
+
printProblems(problems);
|
|
879
|
+
fail("Fix the problems above first");
|
|
880
|
+
}
|
|
881
|
+
const current = await new Remote().current();
|
|
882
|
+
console.log(`${bold(current.name)} ${dim(`${current.manifest?.version ?? "empty"} → ${manifest.version}`)}`);
|
|
883
|
+
printChanges(diffManifests(current.manifest, manifest));
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/cli/index.ts
|
|
887
|
+
const USAGE = `pult <command>
|
|
888
|
+
|
|
889
|
+
build compile definitions into .pult/
|
|
890
|
+
check type-check references and make sure the manifest is current
|
|
891
|
+
diff show what a deploy would change (uses PULT_KEY)
|
|
892
|
+
deploy push the manifest to the environment behind PULT_KEY
|
|
893
|
+
dev rebuild on every change`;
|
|
894
|
+
const { positionals, values } = parseArgs({
|
|
895
|
+
allowPositionals: true,
|
|
896
|
+
options: {
|
|
897
|
+
cwd: { type: "string" },
|
|
898
|
+
help: {
|
|
899
|
+
type: "boolean",
|
|
900
|
+
short: "h"
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
const root = values.cwd ?? process.cwd();
|
|
905
|
+
const commands = {
|
|
906
|
+
build: buildCommand,
|
|
907
|
+
check: checkCommand,
|
|
908
|
+
diff: diffCommand,
|
|
909
|
+
deploy: deployCommand,
|
|
910
|
+
dev: devCommand
|
|
911
|
+
};
|
|
912
|
+
const command = positionals[0];
|
|
913
|
+
if (values.help || !command) console.log(USAGE);
|
|
914
|
+
else if (!commands[command]) fail(`Unknown command "${command}"\n\n${USAGE}`);
|
|
915
|
+
else await commands[command](root);
|
|
916
|
+
//#endregion
|
|
917
|
+
export {};
|