@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/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 {};