@lantharos/pult 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -14
- package/cli.mjs +350 -124
- package/index.d.mts +135 -96
- package/index.mjs +171 -77
- package/package.json +21 -2
- package/{schema-CzG8J_9_.mjs → schema-DuTVNptF.mjs} +65 -42
- package/cli.d.mts +0 -1
- package/stub.d.mts +0 -11
package/cli.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as
|
|
2
|
+
import { C as traceOrigins, L as findActionDef, M as valuesIn, S as kindOf, _ as normalizeFilter, a as collect, c as guards, d as Transport, h as hasPendingAttachments, i as validate, j as shownValue, k as eachBlock, r as toJsonSchema, x as humanize } from "./schema-DuTVNptF.mjs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
4
|
import { parseArgs, styleText } from "node:util";
|
|
4
5
|
import { Cron } from "croner";
|
|
5
6
|
import { existsSync, readFileSync, watch } from "node:fs";
|
|
6
7
|
import { glob, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
8
|
import net from "node:net";
|
|
8
|
-
import { join, relative } from "node:path";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
//#region ../protocol/src/diff.ts
|
|
13
|
-
function keyed(label, before, after,
|
|
13
|
+
function keyed(label, before, after, nested, prefix = "") {
|
|
14
14
|
const changes = [];
|
|
15
15
|
for (const name of Object.keys(after)) if (!(name in before)) changes.push({
|
|
16
16
|
kind: "add",
|
|
@@ -24,24 +24,30 @@ function keyed(label, before, after, inner, prefix = "") {
|
|
|
24
24
|
const a = before[name];
|
|
25
25
|
const b = after[name];
|
|
26
26
|
if (!a || !b) continue;
|
|
27
|
-
|
|
28
|
-
if (nested
|
|
29
|
-
else if (!sameShape(a, b)) changes.push({
|
|
27
|
+
if (nested) changes.push(...nested.diff(name, a, b));
|
|
28
|
+
if (!sameShape(a, b, nested?.covers)) changes.push({
|
|
30
29
|
kind: "change",
|
|
31
30
|
text: `changes ${label} ${prefix}${name}`
|
|
32
31
|
});
|
|
33
32
|
}
|
|
34
33
|
return changes;
|
|
35
34
|
}
|
|
36
|
-
function sameShape(a, b) {
|
|
37
|
-
return
|
|
35
|
+
function sameShape(a, b, covers = []) {
|
|
36
|
+
return shape(a, covers) === shape(b, covers);
|
|
38
37
|
}
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
function shape(definition, covers) {
|
|
39
|
+
const rest = {
|
|
40
|
+
...definition,
|
|
41
|
+
source: void 0
|
|
42
|
+
};
|
|
43
|
+
for (const key of covers) rest[key] = void 0;
|
|
44
|
+
return stableJson(rest);
|
|
45
|
+
}
|
|
46
|
+
function stableJson(value) {
|
|
47
|
+
return JSON.stringify(value, (_, inner) => inner && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a.localeCompare(b))) : inner);
|
|
48
|
+
}
|
|
49
|
+
function byName(spaces) {
|
|
50
|
+
return Object.fromEntries(spaces.map((space) => [space.name, space]));
|
|
45
51
|
}
|
|
46
52
|
function listDelta(subject, verb, before, after) {
|
|
47
53
|
return [...after.filter((x) => !before.includes(x)).map((x) => ({
|
|
@@ -52,6 +58,40 @@ function listDelta(subject, verb, before, after) {
|
|
|
52
58
|
text: `${subject} ${verb[1]} ${x}`
|
|
53
59
|
}))];
|
|
54
60
|
}
|
|
61
|
+
function toggled(before, after, text, kind) {
|
|
62
|
+
if (before === after) return [];
|
|
63
|
+
return [{
|
|
64
|
+
kind: kind ?? (after ? "grant" : "revoke"),
|
|
65
|
+
text: text(after)
|
|
66
|
+
}];
|
|
67
|
+
}
|
|
68
|
+
function delivery(target) {
|
|
69
|
+
return target.mode === "socket" ? "over a socket" : `to ${target.path}`;
|
|
70
|
+
}
|
|
71
|
+
function projectChanges(before, after) {
|
|
72
|
+
return [
|
|
73
|
+
...before.project !== after.project ? [{
|
|
74
|
+
kind: "change",
|
|
75
|
+
text: `moves from project ${before.project} to ${after.project}`
|
|
76
|
+
}] : [],
|
|
77
|
+
...after.environments.filter((name) => !before.environments.includes(name)).map((name) => ({
|
|
78
|
+
kind: "add",
|
|
79
|
+
text: `adds environment ${name}`
|
|
80
|
+
})),
|
|
81
|
+
...before.environments.filter((name) => !after.environments.includes(name)).map((name) => ({
|
|
82
|
+
kind: "remove",
|
|
83
|
+
text: `removes environment ${name}`
|
|
84
|
+
})),
|
|
85
|
+
...before.sync !== after.sync ? [{
|
|
86
|
+
kind: "change",
|
|
87
|
+
text: `${after.sync} follows your code instead of ${before.sync}`
|
|
88
|
+
}] : [],
|
|
89
|
+
...stableJson(before.delivery) !== stableJson(after.delivery) ? [{
|
|
90
|
+
kind: "change",
|
|
91
|
+
text: `sends requests ${delivery(after.delivery)} instead of ${delivery(before.delivery)}`
|
|
92
|
+
}] : []
|
|
93
|
+
];
|
|
94
|
+
}
|
|
55
95
|
function diffManifests(before, after) {
|
|
56
96
|
const base = before ?? {
|
|
57
97
|
...after,
|
|
@@ -62,83 +102,90 @@ function diffManifests(before, after) {
|
|
|
62
102
|
pages: {},
|
|
63
103
|
flags: {},
|
|
64
104
|
roles: {},
|
|
105
|
+
spaces: [],
|
|
65
106
|
notify: {},
|
|
66
107
|
status: null
|
|
67
108
|
};
|
|
68
109
|
return [
|
|
110
|
+
...projectChanges(base, after),
|
|
69
111
|
...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
|
-
}) ? [{
|
|
112
|
+
...base.status && after.status && !sameShape(base.status, after.status, ["components"]) ? [{
|
|
77
113
|
kind: "change",
|
|
78
114
|
text: "changes the status page"
|
|
79
115
|
}] : [],
|
|
80
|
-
...keyed("resource", base.resources, after.resources,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
...a,
|
|
84
|
-
actions: null
|
|
85
|
-
}, {
|
|
86
|
-
...b,
|
|
87
|
-
actions: null
|
|
88
|
-
}) ? [] : [{
|
|
89
|
-
kind: "change",
|
|
90
|
-
text: `changes resource ${name}`
|
|
91
|
-
}], ...actions];
|
|
116
|
+
...keyed("resource", base.resources, after.resources, {
|
|
117
|
+
covers: ["actions"],
|
|
118
|
+
diff: (name, a, b) => keyed("action", a.actions, b.actions, actionChanges(`${name}.`), `${name}.`)
|
|
92
119
|
}),
|
|
93
120
|
...keyed("action", base.actions, after.actions, actionChanges("")),
|
|
94
121
|
...keyed("inbox", base.inboxes, after.inboxes),
|
|
95
122
|
...keyed("view", base.views, after.views),
|
|
96
123
|
...keyed("page", base.pages, after.pages),
|
|
97
|
-
...keyed("flag", base.flags, after.flags,
|
|
98
|
-
|
|
99
|
-
text: `flag ${name} ${b.approval ? "now needs" : "no longer needs"} a second approver`
|
|
100
|
-
}] : []),
|
|
124
|
+
...keyed("flag", base.flags, after.flags, flagChanges),
|
|
125
|
+
...keyed("space", byName(base.spaces), byName(after.spaces)),
|
|
101
126
|
...keyed("notification", base.notify, after.notify),
|
|
102
|
-
...keyed("role", base.roles, after.roles,
|
|
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
|
-
})
|
|
127
|
+
...keyed("role", base.roles, after.roles, roleChanges)
|
|
120
128
|
];
|
|
121
129
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
130
|
+
const flagChanges = {
|
|
131
|
+
covers: ["danger", "approval"],
|
|
132
|
+
diff: (name, a, b) => [...a.danger !== b.danger ? [{
|
|
133
|
+
kind: "change",
|
|
134
|
+
text: `flag ${name} danger ${a.danger} → ${b.danger}`
|
|
135
|
+
}] : [], ...toggled(a.approval, b.approval, (now) => `flag ${name} ${now ? "now needs" : "no longer needs"} a second approver`, "change")]
|
|
136
|
+
};
|
|
137
|
+
const roleChanges = {
|
|
138
|
+
covers: [
|
|
139
|
+
"actions",
|
|
140
|
+
"inboxes",
|
|
141
|
+
"resources",
|
|
142
|
+
"pages",
|
|
143
|
+
"flags",
|
|
144
|
+
"status",
|
|
145
|
+
"reveal"
|
|
146
|
+
],
|
|
147
|
+
diff: (name, a, b) => {
|
|
148
|
+
const role = `role ${name}`;
|
|
125
149
|
return [
|
|
126
|
-
...a.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
...a.
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
}] : []
|
|
150
|
+
...listDelta(role, ["gains", "loses"], a.actions, b.actions),
|
|
151
|
+
...listDelta(role, ["sees inbox", "no longer sees inbox"], a.inboxes, b.inboxes),
|
|
152
|
+
...listDelta(role, ["sees resource", "no longer sees resource"], a.resources, b.resources),
|
|
153
|
+
...listDelta(role, ["sees page", "no longer sees page"], a.pages, b.pages),
|
|
154
|
+
...listDelta(role, ["can change flag", "can no longer change flag"], a.flags, b.flags),
|
|
155
|
+
...toggled(a.status, b.status, (now) => `${role} ${now ? "can" : "can no longer"} post to the status page`),
|
|
156
|
+
...toggled(a.reveal, b.reveal, (now) => `${role} ${now ? "can" : "can no longer"} reveal sensitive fields`)
|
|
138
157
|
];
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
function actionChanges(prefix) {
|
|
161
|
+
return {
|
|
162
|
+
covers: [
|
|
163
|
+
"danger",
|
|
164
|
+
"approval",
|
|
165
|
+
"automatable",
|
|
166
|
+
"schedule"
|
|
167
|
+
],
|
|
168
|
+
diff: (name, a, b) => {
|
|
169
|
+
const action = `action ${prefix}${name}`;
|
|
170
|
+
return [
|
|
171
|
+
...a.danger !== b.danger ? [{
|
|
172
|
+
kind: "change",
|
|
173
|
+
text: `${action} danger ${a.danger} → ${b.danger}`
|
|
174
|
+
}] : [],
|
|
175
|
+
...toggled(a.approval, b.approval, (now) => `${action} ${now ? "now needs" : "no longer needs"} a second approver`, "change"),
|
|
176
|
+
...toggled(a.automatable, b.automatable, (now) => `triage ${now ? "can now" : "can no longer"} run ${action} on its own`),
|
|
177
|
+
...stableJson(a.schedule) !== stableJson(b.schedule) ? [{
|
|
178
|
+
kind: "change",
|
|
179
|
+
text: b.schedule ? `${action} runs on "${b.schedule.cron}"${b.schedule.timezone ? ` in ${b.schedule.timezone}` : ""}` : `${action} no longer runs on a schedule`
|
|
180
|
+
}] : []
|
|
181
|
+
];
|
|
182
|
+
}
|
|
139
183
|
};
|
|
140
184
|
}
|
|
141
185
|
//#endregion
|
|
186
|
+
//#region package.json
|
|
187
|
+
var version = "0.9.0";
|
|
188
|
+
//#endregion
|
|
142
189
|
//#region ../protocol/src/checks.ts
|
|
143
190
|
const COLUMNS = [
|
|
144
191
|
"title",
|
|
@@ -151,6 +198,59 @@ const COLUMNS = [
|
|
|
151
198
|
"tags",
|
|
152
199
|
"due"
|
|
153
200
|
];
|
|
201
|
+
function checkSpaces(manifest, problems) {
|
|
202
|
+
if (!manifest.spaces.length) return;
|
|
203
|
+
const homes = new Set(manifest.spaces.map((space) => space.home));
|
|
204
|
+
const sections = [
|
|
205
|
+
[
|
|
206
|
+
"inbox",
|
|
207
|
+
"inboxes",
|
|
208
|
+
manifest.inboxes
|
|
209
|
+
],
|
|
210
|
+
[
|
|
211
|
+
"resource",
|
|
212
|
+
"resources",
|
|
213
|
+
manifest.resources
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
"page",
|
|
217
|
+
"pages",
|
|
218
|
+
manifest.pages
|
|
219
|
+
],
|
|
220
|
+
[
|
|
221
|
+
"flag",
|
|
222
|
+
"flags",
|
|
223
|
+
manifest.flags
|
|
224
|
+
]
|
|
225
|
+
];
|
|
226
|
+
for (const space of manifest.spaces) {
|
|
227
|
+
if (space.home && !manifest.pages[space.home]) problems.push({
|
|
228
|
+
source: space.source,
|
|
229
|
+
message: `home is unknown page "${space.home}"`
|
|
230
|
+
});
|
|
231
|
+
for (const [kind, key, defined] of sections) for (const name of space[key]) if (!defined[name]) problems.push({
|
|
232
|
+
source: space.source,
|
|
233
|
+
message: `lists unknown ${kind} "${name}"`
|
|
234
|
+
});
|
|
235
|
+
else if (kind === "page" && (name === "home" || homes.has(name))) problems.push({
|
|
236
|
+
source: space.source,
|
|
237
|
+
message: `lists page "${name}", which is already a home`
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const navigable = (kind, name) => {
|
|
241
|
+
if (kind === "page") return name !== "home" && !homes.has(name);
|
|
242
|
+
if (kind === "resource") return manifest.resources[name].searchable || Boolean(manifest.resources[name].list);
|
|
243
|
+
return true;
|
|
244
|
+
};
|
|
245
|
+
for (const [kind, key, defined] of sections) for (const [name, def] of Object.entries(defined)) {
|
|
246
|
+
if (!navigable(kind, name)) continue;
|
|
247
|
+
if (manifest.spaces.some((space) => space[key].includes(name))) continue;
|
|
248
|
+
problems.push({
|
|
249
|
+
source: def.source,
|
|
250
|
+
message: `${kind} ${name} isn't in any space, so only search reaches it`
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
154
254
|
function checkColumns(source, inbox, columns, problems) {
|
|
155
255
|
for (const column of columns) {
|
|
156
256
|
if (COLUMNS.includes(column)) continue;
|
|
@@ -174,11 +274,21 @@ function checkManifest(manifest) {
|
|
|
174
274
|
}
|
|
175
275
|
for (const [name, page] of Object.entries(manifest.pages)) if (page.blocks) checkBlocks(manifest, page.source, `page ${name}`, page.blocks, problems);
|
|
176
276
|
if (manifest.status) checkStatus(manifest, problems);
|
|
177
|
-
for (const [name, flag] of Object.entries(manifest.flags))
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
277
|
+
for (const [name, flag] of Object.entries(manifest.flags)) {
|
|
278
|
+
if (flag.target && !manifest.resources[flag.target]) problems.push({
|
|
279
|
+
source: flag.source,
|
|
280
|
+
message: `flag ${name} targets unknown resource "${flag.target}"`
|
|
281
|
+
});
|
|
282
|
+
for (const guard of flag.guards) if (!(guard.perHour >= 1)) problems.push({
|
|
283
|
+
source: flag.source,
|
|
284
|
+
message: `flag ${name} guards ${guard.inbox} with perHour ${guard.perHour}. Use 1 or more.`
|
|
285
|
+
});
|
|
286
|
+
}
|
|
181
287
|
for (const [name, resource] of Object.entries(manifest.resources)) {
|
|
288
|
+
if (name.includes(".")) problems.push({
|
|
289
|
+
source: resource.source,
|
|
290
|
+
message: `resource "${name}" can't contain "."`
|
|
291
|
+
});
|
|
182
292
|
for (const [relation, def] of Object.entries(resource.related)) if (!manifest.resources[def.resource]) problems.push({
|
|
183
293
|
source: resource.source,
|
|
184
294
|
message: `${name}.${relation} points at unknown resource "${def.resource}"`
|
|
@@ -228,8 +338,14 @@ function checkManifest(manifest) {
|
|
|
228
338
|
source: inbox.source,
|
|
229
339
|
message: `triage runs ${decision.action}, which needs a human (danger "${def.danger}"). Set automatable: true on the action to allow it.`
|
|
230
340
|
});
|
|
341
|
+
const missing = (def?.input?.required ?? []).filter((field) => !(field in (decision.input ?? {})));
|
|
342
|
+
if (missing.length) problems.push({
|
|
343
|
+
source: inbox.source,
|
|
344
|
+
message: `triage runs ${decision.action} without ${missing.join(", ")}, which it needs as input`
|
|
345
|
+
});
|
|
231
346
|
}
|
|
232
347
|
}
|
|
348
|
+
checkSpaces(manifest, problems);
|
|
233
349
|
for (const view of Object.values(manifest.views)) {
|
|
234
350
|
const inbox = manifest.inboxes[view.inbox];
|
|
235
351
|
checkColumns(view.source, inbox, view.columns, problems);
|
|
@@ -295,16 +411,23 @@ function checkBlocks(manifest, source, owner, blocks, problems) {
|
|
|
295
411
|
const action = (id) => {
|
|
296
412
|
if (!findActionDef(manifest, id)) problem(`uses unknown action ${id}`);
|
|
297
413
|
};
|
|
414
|
+
const resource = (name) => {
|
|
415
|
+
if (!manifest.resources[name]) problem(`shows unknown resource ${name}`);
|
|
416
|
+
};
|
|
417
|
+
const value = (field) => {
|
|
418
|
+
if (field && typeof field === "object" && "edit" in field) action(field.edit.action);
|
|
419
|
+
const shown = shownValue(field);
|
|
420
|
+
if (shown && typeof shown === "object" && "record" in shown) resource(shown.record.resource);
|
|
421
|
+
};
|
|
298
422
|
eachBlock(blocks, (block) => {
|
|
299
423
|
if (block.type === "actions") for (const button of block.actions) action(button.action);
|
|
300
424
|
if (block.type === "form") action(block.form.action);
|
|
425
|
+
if (block.type === "record") resource(block.ref.resource);
|
|
301
426
|
if (block.type === "flag" && !manifest.flags[block.flag]) problem(`shows unknown flag ${block.flag}`);
|
|
302
427
|
if ((block.type === "count" || block.type === "items" || block.type === "chart") && "inbox" in block) {
|
|
303
428
|
if (!manifest.inboxes[block.inbox]) problem(`shows unknown inbox ${block.inbox}`);
|
|
304
429
|
}
|
|
305
|
-
|
|
306
|
-
for (const { value } of block.fields) if (value && typeof value === "object" && "edit" in value) action(value.edit.action);
|
|
307
|
-
}
|
|
430
|
+
for (const field of valuesIn(block)) value(field);
|
|
308
431
|
});
|
|
309
432
|
}
|
|
310
433
|
//#endregion
|
|
@@ -328,7 +451,10 @@ function findConfig(root) {
|
|
|
328
451
|
if (!file) throw new Error(`No pult.config.ts in ${root}`);
|
|
329
452
|
return file;
|
|
330
453
|
}
|
|
331
|
-
|
|
454
|
+
function loadProject(root, importer) {
|
|
455
|
+
return traceOrigins(() => load(root, importer));
|
|
456
|
+
}
|
|
457
|
+
async function load(root, importer) {
|
|
332
458
|
const config = (await importer(findConfig(root))).default;
|
|
333
459
|
if (kindOf(config) !== "config") throw new Error("pult.config.ts must default-export defineConfig({ ... })");
|
|
334
460
|
const { include } = config.options;
|
|
@@ -419,10 +545,6 @@ var Locator = class {
|
|
|
419
545
|
return this.locate(origin, new RegExp(`\\b${kind}\\(\\s*["'\`]${escapeRegExp(name)}["'\`]`));
|
|
420
546
|
}
|
|
421
547
|
};
|
|
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
548
|
function pluralize(label) {
|
|
427
549
|
return /(s|x|sh|ch)$/.test(label) ? `${label}es` : /[^aeiou]y$/.test(label) ? `${label.slice(0, -1)}ies` : `${label}s`;
|
|
428
550
|
}
|
|
@@ -551,7 +673,7 @@ function pageDef(locator, page) {
|
|
|
551
673
|
};
|
|
552
674
|
}
|
|
553
675
|
function staticBlocks(page, blocks) {
|
|
554
|
-
if (blocks
|
|
676
|
+
if (hasPendingAttachments(blocks)) throw new Error(`Page "${page}" attaches file data. Render it from a function instead.`);
|
|
555
677
|
return JSON.parse(JSON.stringify(blocks));
|
|
556
678
|
}
|
|
557
679
|
function flagDef(locator, flag) {
|
|
@@ -573,14 +695,34 @@ function flagDef(locator, flag) {
|
|
|
573
695
|
approval: options.approval ?? false
|
|
574
696
|
};
|
|
575
697
|
}
|
|
698
|
+
function spaceDef(locator, space) {
|
|
699
|
+
const { options } = space;
|
|
700
|
+
return {
|
|
701
|
+
name: space.name,
|
|
702
|
+
source: locator.call("space", space.origin, space.name),
|
|
703
|
+
label: options.label ?? humanize(space.name),
|
|
704
|
+
icon: options.icon ?? "layout",
|
|
705
|
+
home: space.home?.name ?? null,
|
|
706
|
+
inboxes: (options.inboxes ?? []).map((inbox) => inbox.name),
|
|
707
|
+
resources: (options.resources ?? []).map((resource) => resource.name),
|
|
708
|
+
pages: (options.pages ?? []).map((page) => page.name),
|
|
709
|
+
flags: (options.flags ?? []).map((flag) => flag.name)
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function byDefinition(a, b) {
|
|
713
|
+
return a.origin.file.localeCompare(b.origin.file) || a.origin.line - b.origin.line;
|
|
714
|
+
}
|
|
576
715
|
function notifyDef(locator, rule) {
|
|
577
716
|
return {
|
|
578
717
|
source: locator.call("notify", rule.origin, rule.name),
|
|
579
718
|
inboxes: rule.options.inboxes.map((inbox) => inbox.name),
|
|
580
719
|
on: rule.options.on,
|
|
581
|
-
to: rule.options.to
|
|
720
|
+
to: recipients(rule.options.to)
|
|
582
721
|
};
|
|
583
722
|
}
|
|
723
|
+
function recipients(list) {
|
|
724
|
+
return list.map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name });
|
|
725
|
+
}
|
|
584
726
|
function logo(value) {
|
|
585
727
|
if (value === void 0) return null;
|
|
586
728
|
return typeof value === "string" ? {
|
|
@@ -606,7 +748,7 @@ function statusDef(locator, page, config) {
|
|
|
606
748
|
monitored: component.monitored ?? false,
|
|
607
749
|
check: component.check ?? null
|
|
608
750
|
}])),
|
|
609
|
-
alert: (page.options.alert ?? [])
|
|
751
|
+
alert: recipients(page.options.alert ?? []),
|
|
610
752
|
after: milliseconds(page.options.after ?? "2m")
|
|
611
753
|
};
|
|
612
754
|
}
|
|
@@ -636,32 +778,59 @@ function assemble(root, definitions, config) {
|
|
|
636
778
|
pages: map(definitions.pages, (page) => pageDef(locator, page)),
|
|
637
779
|
flags: map(definitions.flags, (flag) => flagDef(locator, flag)),
|
|
638
780
|
roles,
|
|
781
|
+
spaces: [...definitions.spaces.values()].sort(byDefinition).map((space) => spaceDef(locator, space)),
|
|
639
782
|
notify: map(definitions.notify, (rule) => notifyDef(locator, rule)),
|
|
640
783
|
status: definitions.status && statusDef(locator, definitions.status, config)
|
|
641
784
|
};
|
|
642
785
|
return {
|
|
643
786
|
...manifest,
|
|
644
|
-
version:
|
|
787
|
+
version: versionOf(manifest)
|
|
645
788
|
};
|
|
646
789
|
}
|
|
647
|
-
function
|
|
648
|
-
return
|
|
790
|
+
function unsourced(definitions) {
|
|
791
|
+
return Object.fromEntries(Object.entries(definitions).map(([name, { source: _, ...rest }]) => [name, rest]));
|
|
792
|
+
}
|
|
793
|
+
function versionOf(manifest) {
|
|
794
|
+
const content = {
|
|
795
|
+
...manifest,
|
|
796
|
+
resources: Object.fromEntries(Object.entries(manifest.resources).map(([name, { source: _, actions, ...rest }]) => [name, {
|
|
797
|
+
...rest,
|
|
798
|
+
actions: unsourced(actions)
|
|
799
|
+
}])),
|
|
800
|
+
actions: unsourced(manifest.actions),
|
|
801
|
+
inboxes: unsourced(manifest.inboxes),
|
|
802
|
+
views: unsourced(manifest.views),
|
|
803
|
+
pages: unsourced(manifest.pages),
|
|
804
|
+
flags: unsourced(manifest.flags),
|
|
805
|
+
roles: unsourced(manifest.roles),
|
|
806
|
+
spaces: manifest.spaces.map(({ source: _, ...rest }) => rest),
|
|
807
|
+
notify: unsourced(manifest.notify),
|
|
808
|
+
status: manifest.status && {
|
|
809
|
+
...manifest.status,
|
|
810
|
+
source: void 0
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
return createHash("sha256").update(stableJson(content)).digest("hex").slice(0, 12);
|
|
649
814
|
}
|
|
650
815
|
//#endregion
|
|
651
816
|
//#region src/build/codegen.ts
|
|
652
817
|
const OUT_DIR = ".pult";
|
|
653
|
-
function projectModule(files) {
|
|
818
|
+
function projectModule(manifest, files) {
|
|
819
|
+
const component = Object.keys(manifest.status?.components ?? {}).map((name) => JSON.stringify(name)).join(" | ") || "never";
|
|
820
|
+
const entries = files.map((file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(`../${file}`)}),`);
|
|
654
821
|
return [
|
|
655
822
|
"// Generated by `pult build`. Commit this file; don't edit it.",
|
|
656
|
-
"import type { Manifest, Project } from \"pult\"",
|
|
823
|
+
"import type { Manifest, Project } from \"@lantharos/pult\"",
|
|
657
824
|
"import manifest from \"./manifest.json\" with { type: \"json\" }",
|
|
658
825
|
"",
|
|
659
|
-
|
|
660
|
-
|
|
826
|
+
`const project: Project<${component}> = {`,
|
|
827
|
+
` manifest: manifest as Manifest<${component}>,`,
|
|
661
828
|
" modules: {",
|
|
662
|
-
...
|
|
829
|
+
...entries,
|
|
663
830
|
" },",
|
|
664
|
-
"}
|
|
831
|
+
"}",
|
|
832
|
+
"",
|
|
833
|
+
"export default project",
|
|
665
834
|
""
|
|
666
835
|
].join("\n");
|
|
667
836
|
}
|
|
@@ -676,7 +845,7 @@ async function writeOutput(root, manifest, files) {
|
|
|
676
845
|
const dir = join(root, OUT_DIR);
|
|
677
846
|
await mkdir(dir, { recursive: true });
|
|
678
847
|
await writeFile(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
679
|
-
await writeFile(join(dir, "index.ts"), projectModule(files));
|
|
848
|
+
await writeFile(join(dir, "index.ts"), projectModule(manifest, files));
|
|
680
849
|
}
|
|
681
850
|
//#endregion
|
|
682
851
|
//#region src/build/index.ts
|
|
@@ -765,34 +934,18 @@ async function buildProject(root) {
|
|
|
765
934
|
}
|
|
766
935
|
}
|
|
767
936
|
var Remote = class {
|
|
768
|
-
|
|
769
|
-
#secret;
|
|
770
|
-
#url;
|
|
937
|
+
#transport;
|
|
771
938
|
constructor() {
|
|
772
939
|
const key = process.env.PULT_KEY;
|
|
773
940
|
if (!key) fail("Set PULT_KEY to the key of the environment you're targeting");
|
|
774
|
-
|
|
775
|
-
this.environment = parsed.environment;
|
|
776
|
-
this.#secret = parsed.secret;
|
|
777
|
-
this.#url = (process.env.PULT_URL ?? "https://app.pult.sh").replace(/\/$/, "");
|
|
941
|
+
this.#transport = new Transport(key, process.env.PULT_URL ?? "https://app.pult.sh", fetch);
|
|
778
942
|
}
|
|
779
943
|
async #request(method, path, body) {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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;
|
|
944
|
+
try {
|
|
945
|
+
return await this.#transport.request(method, path, body);
|
|
946
|
+
} catch (error) {
|
|
947
|
+
fail(error.message);
|
|
948
|
+
}
|
|
796
949
|
}
|
|
797
950
|
current() {
|
|
798
951
|
return this.#request("GET", "/v1/manifest");
|
|
@@ -819,7 +972,7 @@ async function checkCommand(root) {
|
|
|
819
972
|
const { manifest, problems } = await buildProject(root);
|
|
820
973
|
printProblems(problems);
|
|
821
974
|
if (problems.length) fail(`${problems.length} problem${problems.length === 1 ? "" : "s"}`);
|
|
822
|
-
if ((await readManifest(root))
|
|
975
|
+
if (stableJson(await readManifest(root)) !== stableJson(manifest)) fail("The committed manifest is out of date. Run `pult build`.");
|
|
823
976
|
done(`manifest ${manifest.version} is valid and up to date`);
|
|
824
977
|
}
|
|
825
978
|
//#endregion
|
|
@@ -883,14 +1036,81 @@ async function diffCommand(root) {
|
|
|
883
1036
|
printChanges(diffManifests(current.manifest, manifest));
|
|
884
1037
|
}
|
|
885
1038
|
//#endregion
|
|
1039
|
+
//#region src/cli/commands/init.ts
|
|
1040
|
+
const WRANGLER_FILES = [
|
|
1041
|
+
"wrangler.jsonc",
|
|
1042
|
+
"wrangler.json",
|
|
1043
|
+
"wrangler.toml"
|
|
1044
|
+
];
|
|
1045
|
+
const WEBHOOK_PATH = "/pult";
|
|
1046
|
+
function config(project, onWorkers) {
|
|
1047
|
+
return `import { defineConfig } from "@lantharos/pult"
|
|
1048
|
+
|
|
1049
|
+
export default defineConfig({
|
|
1050
|
+
project: ${JSON.stringify(project)},
|
|
1051
|
+
include: ["pult/**/*.ts"],${onWorkers ? `\n delivery: { mode: "webhook", path: "${WEBHOOK_PATH}" },` : ""}
|
|
1052
|
+
})
|
|
1053
|
+
`;
|
|
1054
|
+
}
|
|
1055
|
+
const DEFINITIONS = `import { inbox, resource } from "@lantharos/pult"
|
|
1056
|
+
|
|
1057
|
+
export const user = resource("user", {
|
|
1058
|
+
get: (id) => ({ id, email: \`\${id}@example.com\` }),
|
|
1059
|
+
title: (user) => user.email,
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
export const feedback = inbox("feedback", { resource: user })
|
|
1063
|
+
`;
|
|
1064
|
+
const SOCKET_CLIENT = `import { createPult } from "@lantharos/pult"
|
|
1065
|
+
import project from "../.pult"
|
|
1066
|
+
|
|
1067
|
+
export const pult = createPult({ project })
|
|
1068
|
+
|
|
1069
|
+
pult.listen()
|
|
1070
|
+
`;
|
|
1071
|
+
const WORKER_CLIENT = `import { env } from "cloudflare:workers"
|
|
1072
|
+
import { createPult } from "@lantharos/pult"
|
|
1073
|
+
import project from "../.pult"
|
|
1074
|
+
|
|
1075
|
+
export const pult = createPult({ key: env.PULT_KEY, project })
|
|
1076
|
+
export const handlePult = pult.handler()
|
|
1077
|
+
`;
|
|
1078
|
+
async function projectName(root) {
|
|
1079
|
+
return ((await readFile(join(root, "package.json"), "utf8").then((text) => JSON.parse(text)).catch(() => ({ name: void 0 }))).name ?? basename(root)).replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "app";
|
|
1080
|
+
}
|
|
1081
|
+
async function create(root, file, content) {
|
|
1082
|
+
const path = join(root, file);
|
|
1083
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1084
|
+
try {
|
|
1085
|
+
await writeFile(path, content, { flag: "wx" });
|
|
1086
|
+
done(`wrote ${file}`);
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
if (error.code !== "EEXIST") throw error;
|
|
1089
|
+
console.log(dim(` kept ${file}, it already exists`));
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
async function initCommand(root) {
|
|
1093
|
+
const onWorkers = WRANGLER_FILES.some((file) => existsSync(join(root, file)));
|
|
1094
|
+
await create(root, "pult.config.ts", config(await projectName(root), onWorkers));
|
|
1095
|
+
await create(root, "pult/feedback.ts", DEFINITIONS);
|
|
1096
|
+
await create(root, "src/pult.ts", onWorkers ? WORKER_CLIENT : SOCKET_CLIENT);
|
|
1097
|
+
console.log(`\nNext, run ${bold("pult build")} and set PULT_KEY to your environment's key.`);
|
|
1098
|
+
if (onWorkers) console.log(`Then hand Pult's requests to it in your fetch handler:\n\n if (new URL(request.url).pathname === "${WEBHOOK_PATH}") return handlePult(request, ctx)`);
|
|
1099
|
+
}
|
|
1100
|
+
//#endregion
|
|
886
1101
|
//#region src/cli/index.ts
|
|
887
|
-
const USAGE = `pult <command>
|
|
1102
|
+
const USAGE = `pult <command> [--cwd <path>]
|
|
888
1103
|
|
|
1104
|
+
init set up Pult in this project
|
|
889
1105
|
build compile definitions into .pult/
|
|
890
|
-
check
|
|
1106
|
+
check find broken references and make sure .pult/ is current
|
|
891
1107
|
diff show what a deploy would change (uses PULT_KEY)
|
|
892
1108
|
deploy push the manifest to the environment behind PULT_KEY
|
|
893
|
-
dev rebuild on every change
|
|
1109
|
+
dev rebuild on every change
|
|
1110
|
+
|
|
1111
|
+
--cwd <path> run in another project folder
|
|
1112
|
+
-v, --version print the version
|
|
1113
|
+
-h, --help show this help`;
|
|
894
1114
|
const { positionals, values } = parseArgs({
|
|
895
1115
|
allowPositionals: true,
|
|
896
1116
|
options: {
|
|
@@ -898,11 +1118,16 @@ const { positionals, values } = parseArgs({
|
|
|
898
1118
|
help: {
|
|
899
1119
|
type: "boolean",
|
|
900
1120
|
short: "h"
|
|
1121
|
+
},
|
|
1122
|
+
version: {
|
|
1123
|
+
type: "boolean",
|
|
1124
|
+
short: "v"
|
|
901
1125
|
}
|
|
902
1126
|
}
|
|
903
1127
|
});
|
|
904
|
-
const root = values.cwd ??
|
|
1128
|
+
const root = resolve(values.cwd ?? ".");
|
|
905
1129
|
const commands = {
|
|
1130
|
+
init: initCommand,
|
|
906
1131
|
build: buildCommand,
|
|
907
1132
|
check: checkCommand,
|
|
908
1133
|
diff: diffCommand,
|
|
@@ -910,7 +1135,8 @@ const commands = {
|
|
|
910
1135
|
dev: devCommand
|
|
911
1136
|
};
|
|
912
1137
|
const command = positionals[0];
|
|
913
|
-
if (values.
|
|
1138
|
+
if (values.version) console.log(version);
|
|
1139
|
+
else if (values.help || !command) console.log(USAGE);
|
|
914
1140
|
else if (!commands[command]) fail(`Unknown command "${command}"\n\n${USAGE}`);
|
|
915
1141
|
else await commands[command](root);
|
|
916
1142
|
//#endregion
|