@lantharos/pult 0.0.0-stage → 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/cli.mjs ADDED
@@ -0,0 +1,1143 @@
1
+ #!/usr/bin/env node
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";
4
+ import { parseArgs, styleText } from "node:util";
5
+ import { Cron } from "croner";
6
+ import { existsSync, readFileSync, watch } from "node:fs";
7
+ import { glob, mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import net from "node:net";
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, nested, 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
+ if (nested) changes.push(...nested.diff(name, a, b));
28
+ if (!sameShape(a, b, nested?.covers)) changes.push({
29
+ kind: "change",
30
+ text: `changes ${label} ${prefix}${name}`
31
+ });
32
+ }
33
+ return changes;
34
+ }
35
+ function sameShape(a, b, covers = []) {
36
+ return shape(a, covers) === shape(b, covers);
37
+ }
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]));
51
+ }
52
+ function listDelta(subject, verb, before, after) {
53
+ return [...after.filter((x) => !before.includes(x)).map((x) => ({
54
+ kind: "grant",
55
+ text: `${subject} ${verb[0]} ${x}`
56
+ })), ...before.filter((x) => !after.includes(x)).map((x) => ({
57
+ kind: "revoke",
58
+ text: `${subject} ${verb[1]} ${x}`
59
+ }))];
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
+ }
95
+ function diffManifests(before, after) {
96
+ const base = before ?? {
97
+ ...after,
98
+ resources: {},
99
+ actions: {},
100
+ inboxes: {},
101
+ views: {},
102
+ pages: {},
103
+ flags: {},
104
+ roles: {},
105
+ spaces: [],
106
+ notify: {},
107
+ status: null
108
+ };
109
+ return [
110
+ ...projectChanges(base, after),
111
+ ...keyed("status component", base.status?.components ?? {}, after.status?.components ?? {}),
112
+ ...base.status && after.status && !sameShape(base.status, after.status, ["components"]) ? [{
113
+ kind: "change",
114
+ text: "changes the status page"
115
+ }] : [],
116
+ ...keyed("resource", base.resources, after.resources, {
117
+ covers: ["actions"],
118
+ diff: (name, a, b) => keyed("action", a.actions, b.actions, actionChanges(`${name}.`), `${name}.`)
119
+ }),
120
+ ...keyed("action", base.actions, after.actions, actionChanges("")),
121
+ ...keyed("inbox", base.inboxes, after.inboxes),
122
+ ...keyed("view", base.views, after.views),
123
+ ...keyed("page", base.pages, after.pages),
124
+ ...keyed("flag", base.flags, after.flags, flagChanges),
125
+ ...keyed("space", byName(base.spaces), byName(after.spaces)),
126
+ ...keyed("notification", base.notify, after.notify),
127
+ ...keyed("role", base.roles, after.roles, roleChanges)
128
+ ];
129
+ }
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}`;
149
+ return [
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`)
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
+ }
183
+ };
184
+ }
185
+ //#endregion
186
+ //#region package.json
187
+ var version = "0.9.0";
188
+ //#endregion
189
+ //#region ../protocol/src/checks.ts
190
+ const COLUMNS = [
191
+ "title",
192
+ "state",
193
+ "occurrences",
194
+ "users",
195
+ "lastSeen",
196
+ "firstSeen",
197
+ "assignee",
198
+ "tags",
199
+ "due"
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
+ }
254
+ function checkColumns(source, inbox, columns, problems) {
255
+ for (const column of columns) {
256
+ if (COLUMNS.includes(column)) continue;
257
+ const field = column.startsWith("data.") ? column.slice(5) : null;
258
+ if (field && inbox.data?.properties?.[field]) continue;
259
+ problems.push({
260
+ source,
261
+ message: `column "${column}" doesn't exist on inbox data`
262
+ });
263
+ }
264
+ }
265
+ function checkManifest(manifest) {
266
+ const problems = [];
267
+ const actionExists = (id) => id.endsWith(".*") ? Boolean(manifest.resources[id.slice(0, -2)]) : Boolean(findActionDef(manifest, id));
268
+ for (const [name, action] of Object.entries(manifest.actions)) {
269
+ if (name.includes(".") || name.startsWith("flag:")) problems.push({
270
+ source: action.source,
271
+ message: `action "${name}" can't contain "." or start with "flag:"`
272
+ });
273
+ if (action.schedule) checkSchedule(name, action, problems);
274
+ }
275
+ for (const [name, page] of Object.entries(manifest.pages)) if (page.blocks) checkBlocks(manifest, page.source, `page ${name}`, page.blocks, problems);
276
+ if (manifest.status) checkStatus(manifest, problems);
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
+ }
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
+ });
292
+ for (const [relation, def] of Object.entries(resource.related)) if (!manifest.resources[def.resource]) problems.push({
293
+ source: resource.source,
294
+ message: `${name}.${relation} points at unknown resource "${def.resource}"`
295
+ });
296
+ for (const [key, action] of Object.entries(resource.actions)) {
297
+ const inputs = action.input?.properties ?? {};
298
+ for (const field of [...Object.keys(action.pick), ...action.choices]) if (!inputs[field]) problems.push({
299
+ source: action.source,
300
+ message: `${name}.${key} has no input field "${field}"`
301
+ });
302
+ for (const [field, target] of Object.entries(action.pick)) if (!manifest.resources[target]) problems.push({
303
+ source: action.source,
304
+ message: `${name}.${key} picks ${field} from unknown resource "${target}"`
305
+ });
306
+ else if (!manifest.resources[target].searchable) problems.push({
307
+ source: action.source,
308
+ message: `${name}.${key} picks ${field} from ${target}, which needs a find function to be searchable`
309
+ });
310
+ }
311
+ }
312
+ for (const role of Object.values(manifest.roles)) for (const id of role.actions) if (!actionExists(id)) problems.push({
313
+ source: role.source,
314
+ message: `grants unknown action ${id}`
315
+ });
316
+ for (const inbox of Object.values(manifest.inboxes)) {
317
+ checkColumns(inbox.source, inbox, inbox.columns, problems);
318
+ for (const state of inbox.reopen) if (!inbox.states.includes(state)) problems.push({
319
+ source: inbox.source,
320
+ message: `reopens from unknown state "${state}"`
321
+ });
322
+ for (const rule of inbox.triage?.rules ?? []) for (const decision of rule.then) {
323
+ if (decision.type === "state" && !inbox.states.includes(decision.state)) problems.push({
324
+ source: inbox.source,
325
+ message: `${rule.name} moves items to unknown state "${decision.state}"`
326
+ });
327
+ if (decision.type !== "run") continue;
328
+ const def = findActionDef(manifest, decision.action);
329
+ if (!def) problems.push({
330
+ source: inbox.source,
331
+ message: `triage runs unknown action ${decision.action}`
332
+ });
333
+ else if (def.approval) problems.push({
334
+ source: inbox.source,
335
+ message: `triage runs ${decision.action}, which needs a second approver, so triage can't run it`
336
+ });
337
+ else if (def.danger !== "none" && !def.automatable) problems.push({
338
+ source: inbox.source,
339
+ message: `triage runs ${decision.action}, which needs a human (danger "${def.danger}"). Set automatable: true on the action to allow it.`
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
+ });
346
+ }
347
+ }
348
+ checkSpaces(manifest, problems);
349
+ for (const view of Object.values(manifest.views)) {
350
+ const inbox = manifest.inboxes[view.inbox];
351
+ checkColumns(view.source, inbox, view.columns, problems);
352
+ for (const state of view.filter.state ?? []) if (!inbox.states.includes(state)) problems.push({
353
+ source: view.source,
354
+ message: `filters on unknown state "${state}"`
355
+ });
356
+ }
357
+ return problems;
358
+ }
359
+ function checkSchedule(name, action, problems) {
360
+ const { cron, timezone } = action.schedule;
361
+ try {
362
+ new Cron(cron, {
363
+ timezone: timezone ?? void 0,
364
+ paused: true
365
+ });
366
+ } catch (error) {
367
+ problems.push({
368
+ source: action.source,
369
+ message: `${name} has an invalid schedule: ${error.message}`
370
+ });
371
+ }
372
+ if (action.input?.required?.length) problems.push({
373
+ source: action.source,
374
+ message: `${name} runs on a schedule, so ${action.input.required.join(", ")} need defaults in its input`
375
+ });
376
+ if (action.approval) problems.push({
377
+ source: action.source,
378
+ message: `${name} runs on a schedule, so it can't need a second approver`
379
+ });
380
+ }
381
+ const COMPONENT = /^[a-z0-9][a-z0-9-]*$/;
382
+ const WEB_ADDRESS = /^https?:\/\/[^\s/$.?#].\S*$/;
383
+ const CSS_LIMIT = 5e4;
384
+ function checkStatus(manifest, problems) {
385
+ const status = manifest.status;
386
+ const problem = (message) => problems.push({
387
+ source: status.source,
388
+ message: `status page ${message}`
389
+ });
390
+ const components = Object.entries(status.components);
391
+ if (!components.length) problem("needs at least one component");
392
+ for (const [name, component] of components) {
393
+ if (!COMPONENT.test(name)) problem(`component "${name}" should be lowercase letters, numbers and dashes`);
394
+ if (component.monitored && component.check) problem(`component ${name} can follow your app or a check, not both`);
395
+ if (component.check && !WEB_ADDRESS.test(component.check)) problem(`component ${name} checks "${component.check}", which isn't a web address`);
396
+ }
397
+ for (const recipient of status.alert) if ("role" in recipient && !manifest.roles[recipient.role]) problem(`alerts role "${recipient.role}", which doesn't exist`);
398
+ if (status.after < 6e4) problem("waits less than a minute before calling your app down. Use 1m or more.");
399
+ for (const [what, url] of [
400
+ ["logo", status.logo?.light],
401
+ ["dark logo", status.logo?.dark],
402
+ ["website", status.website]
403
+ ]) if (url && !WEB_ADDRESS.test(url)) problem(`${what} "${url}" isn't a web address`);
404
+ 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.`);
405
+ }
406
+ function checkBlocks(manifest, source, owner, blocks, problems) {
407
+ const problem = (message) => problems.push({
408
+ source,
409
+ message: `${owner} ${message}`
410
+ });
411
+ const action = (id) => {
412
+ if (!findActionDef(manifest, id)) problem(`uses unknown action ${id}`);
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
+ };
422
+ eachBlock(blocks, (block) => {
423
+ if (block.type === "actions") for (const button of block.actions) action(button.action);
424
+ if (block.type === "form") action(block.form.action);
425
+ if (block.type === "record") resource(block.ref.resource);
426
+ if (block.type === "flag" && !manifest.flags[block.flag]) problem(`shows unknown flag ${block.flag}`);
427
+ if ((block.type === "count" || block.type === "items" || block.type === "chart") && "inbox" in block) {
428
+ if (!manifest.inboxes[block.inbox]) problem(`shows unknown inbox ${block.inbox}`);
429
+ }
430
+ for (const field of valuesIn(block)) value(field);
431
+ });
432
+ }
433
+ //#endregion
434
+ //#region src/build/check.ts
435
+ function check(manifest, impurities) {
436
+ return [...impurities.map(({ file, effect }) => ({
437
+ source: file,
438
+ message: `has a top-level side effect: ${effect}. Move it inside a handler or import it lazily.`
439
+ })), ...checkManifest(manifest)];
440
+ }
441
+ //#endregion
442
+ //#region src/build/load.ts
443
+ const CONFIG_FILES = [
444
+ "pult.config.ts",
445
+ "pult.config.mts",
446
+ "pult.config.js",
447
+ "pult.config.mjs"
448
+ ];
449
+ function findConfig(root) {
450
+ const file = CONFIG_FILES.map((name) => join(root, name)).find(existsSync);
451
+ if (!file) throw new Error(`No pult.config.ts in ${root}`);
452
+ return file;
453
+ }
454
+ function loadProject(root, importer) {
455
+ return traceOrigins(() => load(root, importer));
456
+ }
457
+ async function load(root, importer) {
458
+ const config = (await importer(findConfig(root))).default;
459
+ if (kindOf(config) !== "config") throw new Error("pult.config.ts must default-export defineConfig({ ... })");
460
+ const { include } = config.options;
461
+ const files = /* @__PURE__ */ new Set();
462
+ for (const pattern of include) for await (const file of glob(pattern, {
463
+ cwd: root,
464
+ exclude: (name) => name.includes("node_modules")
465
+ })) files.add(relative(root, join(root, file)));
466
+ const impurities = [];
467
+ const modules = [];
468
+ for (const file of [...files].sort()) {
469
+ const restore = trap((effect) => impurities.push({
470
+ file,
471
+ effect
472
+ }));
473
+ try {
474
+ modules.push(await importer(join(root, file)));
475
+ } catch (error) {
476
+ if (!impurities.some((impurity) => impurity.file === file)) throw error;
477
+ } finally {
478
+ restore();
479
+ }
480
+ }
481
+ return {
482
+ root,
483
+ config,
484
+ files: [...files].sort(),
485
+ modules,
486
+ impurities
487
+ };
488
+ }
489
+ function trap(report) {
490
+ const original = {
491
+ fetch: globalThis.fetch,
492
+ WebSocket: globalThis.WebSocket,
493
+ setInterval: globalThis.setInterval,
494
+ connect: net.Socket.prototype.connect
495
+ };
496
+ const refuse = (effect) => {
497
+ report(effect);
498
+ return /* @__PURE__ */ new Error(`Definition files must not ${effect} when imported`);
499
+ };
500
+ globalThis.fetch = Object.assign((input) => Promise.reject(refuse(`fetch ${input instanceof Request ? input.url : input}`)), original.fetch);
501
+ globalThis.WebSocket = class {
502
+ constructor(url) {
503
+ throw refuse(`open a WebSocket to ${url}`);
504
+ }
505
+ };
506
+ globalThis.setInterval = ((..._) => {
507
+ throw refuse("start an interval");
508
+ });
509
+ net.Socket.prototype.connect = function() {
510
+ throw refuse("open a network connection");
511
+ };
512
+ return () => {
513
+ globalThis.fetch = original.fetch;
514
+ globalThis.WebSocket = original.WebSocket;
515
+ globalThis.setInterval = original.setInterval;
516
+ net.Socket.prototype.connect = original.connect;
517
+ };
518
+ }
519
+ //#endregion
520
+ //#region src/build/manifest.ts
521
+ const sources = /* @__PURE__ */ new Map();
522
+ function lines(file) {
523
+ let cached = sources.get(file);
524
+ if (!cached) {
525
+ cached = readFileSync(file, "utf8").split("\n");
526
+ sources.set(file, cached);
527
+ }
528
+ return cached;
529
+ }
530
+ function escapeRegExp(text) {
531
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
532
+ }
533
+ var Locator = class {
534
+ root;
535
+ constructor(root) {
536
+ this.root = root;
537
+ }
538
+ locate(origin, pattern, after = 0) {
539
+ const file = relative(this.root, origin.file);
540
+ if (!pattern || !origin.file) return `${file}:${origin.line}`;
541
+ const index = lines(origin.file).findIndex((line, i) => i >= after && pattern.test(line));
542
+ return `${file}:${index === -1 ? origin.line : index + 1}`;
543
+ }
544
+ call(kind, origin, name) {
545
+ return this.locate(origin, new RegExp(`\\b${kind}\\(\\s*["'\`]${escapeRegExp(name)}["'\`]`));
546
+ }
547
+ };
548
+ function pluralize(label) {
549
+ return /(s|x|sh|ch)$/.test(label) ? `${label}es` : /[^aeiou]y$/.test(label) ? `${label.slice(0, -1)}ies` : `${label}s`;
550
+ }
551
+ function actionDef(source, key, action) {
552
+ const { options } = action;
553
+ return {
554
+ source,
555
+ label: options.label ?? humanize(key),
556
+ description: options.description,
557
+ danger: options.danger ?? "none",
558
+ input: options.input ? toJsonSchema(options.input) : null,
559
+ preview: Boolean(options.preview),
560
+ automatable: options.automatable ?? false,
561
+ approval: options.approval ?? false,
562
+ contextual: Boolean(options.when || options.defaults),
563
+ background: options.background ?? false,
564
+ pick: options.pick ?? {},
565
+ choices: Object.keys(options.choices ?? {}),
566
+ schedule: options.schedule ? schedule(options.schedule) : null
567
+ };
568
+ }
569
+ function schedule(input) {
570
+ return typeof input === "string" ? {
571
+ cron: input,
572
+ timezone: null
573
+ } : {
574
+ cron: input.cron,
575
+ timezone: input.timezone ?? null
576
+ };
577
+ }
578
+ function resourceDef(locator, resource) {
579
+ const source = locator.call("resource", resource.origin, resource.name);
580
+ const line = Number(source.split(":").pop()) - 1;
581
+ const label = resource.options.label ?? humanize(resource.name);
582
+ const actions = {};
583
+ for (const [key, action] of Object.entries(resource.actions)) {
584
+ if (kindOf(action) !== "action") continue;
585
+ const keyPattern = new RegExp(`(^|[\\s{,"'])${escapeRegExp(key)}["']?\\s*:\\s*action\\(`);
586
+ actions[key] = actionDef(locator.locate(resource.origin, keyPattern, line), key, action);
587
+ }
588
+ return {
589
+ source,
590
+ label,
591
+ plural: resource.options.plural ?? pluralize(label),
592
+ icon: resource.options.icon ?? "box",
593
+ searchable: Boolean(resource.options.find),
594
+ list: resource.options.list ? { filters: resource.options.list.filters ? toJsonSchema(resource.options.list.filters) : null } : null,
595
+ related: Object.fromEntries(Object.entries(resource.options.related ?? {}).map(([name, relation]) => [name, {
596
+ resource: relation.resource,
597
+ label: relation.label ?? humanize(name)
598
+ }])),
599
+ actions
600
+ };
601
+ }
602
+ function inboxDef(locator, inbox) {
603
+ const { options } = inbox;
604
+ const intake = options.intake === true ? {} : options.intake || null;
605
+ const label = options.label ?? humanize(inbox.name);
606
+ return {
607
+ source: locator.call("inbox", inbox.origin, inbox.name),
608
+ label,
609
+ icon: options.icon ?? "inbox",
610
+ resource: options.resource?.name ?? null,
611
+ layout: options.layout ?? "list",
612
+ states: inbox.states,
613
+ closed: inbox.closed,
614
+ reopen: options.reopenOn ? options.reopenFrom ?? inbox.closed : [],
615
+ data: options.data ? toJsonSchema(options.data) : null,
616
+ columns: options.columns ?? defaultColumns(options.layout ?? "list"),
617
+ intake: intake && {
618
+ title: intake.title ?? label,
619
+ description: intake.description
620
+ },
621
+ triage: options.triage ? {
622
+ instructions: options.triage.instructions,
623
+ questions: options.triage.questions,
624
+ rules: options.triage.rules.map((rule, index) => ({
625
+ name: rule.name ?? `Rule ${index + 1}`,
626
+ live: rule.live ?? false,
627
+ then: [rule.then].flat()
628
+ }))
629
+ } : null,
630
+ reply: Boolean(options.reply),
631
+ hooks: { state: Boolean(options.onState) },
632
+ sla: Object.fromEntries(Object.entries(options.sla ?? {}).map(([state, duration]) => [state, milliseconds(duration)]))
633
+ };
634
+ }
635
+ const UNITS = {
636
+ m: 6e4,
637
+ h: 36e5,
638
+ d: 864e5
639
+ };
640
+ function milliseconds(duration) {
641
+ const match = /^(\d+(?:\.\d+)?)([mhd])$/.exec(duration);
642
+ if (!match) throw new Error(`"${duration}" isn't a duration. Use something like "30m", "4h" or "2d".`);
643
+ return Number(match[1]) * UNITS[match[2]];
644
+ }
645
+ function defaultColumns(layout) {
646
+ return layout === "table" ? [
647
+ "title",
648
+ "occurrences",
649
+ "users",
650
+ "lastSeen"
651
+ ] : ["title", "lastSeen"];
652
+ }
653
+ function viewDef(locator, view) {
654
+ const { options } = view;
655
+ return {
656
+ source: locator.call("view", view.origin, view.name),
657
+ label: options.label ?? humanize(view.name),
658
+ inbox: options.inbox.name,
659
+ filter: normalizeFilter(options.filter),
660
+ layout: options.layout ?? options.inbox.options.layout ?? "list",
661
+ columns: options.columns ?? options.inbox.options.columns ?? defaultColumns(options.layout ?? "list"),
662
+ sort: options.sort ?? "-lastSeen"
663
+ };
664
+ }
665
+ function pageDef(locator, page) {
666
+ const { options } = page;
667
+ return {
668
+ source: page.name === "home" ? locator.locate(page.origin) : locator.call("page", page.origin, page.name),
669
+ label: options.label ?? humanize(page.name),
670
+ icon: options.icon ?? "layout",
671
+ params: options.params ? toJsonSchema(options.params) : null,
672
+ blocks: typeof options.render === "function" ? null : staticBlocks(page.name, options.render)
673
+ };
674
+ }
675
+ function staticBlocks(page, blocks) {
676
+ if (hasPendingAttachments(blocks)) throw new Error(`Page "${page}" attaches file data. Render it from a function instead.`);
677
+ return JSON.parse(JSON.stringify(blocks));
678
+ }
679
+ function flagDef(locator, flag) {
680
+ const { options } = flag;
681
+ return {
682
+ source: locator.call("flag", flag.origin, flag.name),
683
+ label: options.label ?? humanize(flag.name),
684
+ description: options.description,
685
+ value: options.value ? toJsonSchema(options.value) : { type: "boolean" },
686
+ default: JSON.parse(JSON.stringify(options.default ?? null)),
687
+ target: options.target?.name ?? null,
688
+ attributes: options.attributes ? toJsonSchema(options.attributes) : null,
689
+ guards: guards(flag).map((guard) => ({
690
+ inbox: guard.inbox.name,
691
+ perHour: guard.perHour,
692
+ then: guard.then ?? "pause"
693
+ })),
694
+ danger: options.danger ?? "none",
695
+ approval: options.approval ?? false
696
+ };
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
+ }
715
+ function notifyDef(locator, rule) {
716
+ return {
717
+ source: locator.call("notify", rule.origin, rule.name),
718
+ inboxes: rule.options.inboxes.map((inbox) => inbox.name),
719
+ on: rule.options.on,
720
+ to: recipients(rule.options.to)
721
+ };
722
+ }
723
+ function recipients(list) {
724
+ return list.map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name });
725
+ }
726
+ function logo(value) {
727
+ if (value === void 0) return null;
728
+ return typeof value === "string" ? {
729
+ light: value,
730
+ dark: value
731
+ } : value;
732
+ }
733
+ function statusDef(locator, page, config) {
734
+ return {
735
+ source: locator.locate(page.origin, /\bstatus\(/),
736
+ title: page.options.title ?? humanize(config.options.project),
737
+ logo: logo(page.options.logo),
738
+ website: page.options.website ?? null,
739
+ css: page.options.css?.trim() || null,
740
+ components: Object.fromEntries(Object.entries(page.options.components).map(([name, component]) => [name, typeof component === "string" ? {
741
+ label: component,
742
+ description: null,
743
+ monitored: false,
744
+ check: null
745
+ } : {
746
+ label: component.label,
747
+ description: component.description ?? null,
748
+ monitored: component.monitored ?? false,
749
+ check: component.check ?? null
750
+ }])),
751
+ alert: recipients(page.options.alert ?? []),
752
+ after: milliseconds(page.options.after ?? "2m")
753
+ };
754
+ }
755
+ function assemble(root, definitions, config) {
756
+ const locator = new Locator(root);
757
+ const map = (source, convert) => Object.fromEntries([...source].sort(([a], [b]) => a.localeCompare(b)).map(([name, value]) => [name, convert(value)]));
758
+ const roles = map(definitions.roles, (role) => ({
759
+ source: locator.call("role", role.origin, role.name),
760
+ label: role.options.label ?? humanize(role.name),
761
+ inboxes: (role.options.inboxes ?? []).map((inbox) => inbox.name),
762
+ resources: (role.options.resources ?? []).map((resource) => resource.name),
763
+ actions: (role.options.actions ?? []).map((action) => action.id),
764
+ pages: (role.options.pages ?? []).map((page) => page.name),
765
+ flags: (role.options.flags ?? []).map((flag) => flag.name),
766
+ reveal: role.options.reveal ?? false,
767
+ status: role.options.status ?? false
768
+ }));
769
+ const manifest = {
770
+ project: config.options.project,
771
+ environments: config.options.environments,
772
+ sync: config.options.sync,
773
+ delivery: config.options.delivery,
774
+ resources: map(definitions.resources, (resource) => resourceDef(locator, resource)),
775
+ actions: map(definitions.actions, (action) => actionDef(locator.call("action", action.origin, action.name), action.name, action)),
776
+ inboxes: map(definitions.inboxes, (inbox) => inboxDef(locator, inbox)),
777
+ views: map(definitions.views, (view) => viewDef(locator, view)),
778
+ pages: map(definitions.pages, (page) => pageDef(locator, page)),
779
+ flags: map(definitions.flags, (flag) => flagDef(locator, flag)),
780
+ roles,
781
+ spaces: [...definitions.spaces.values()].sort(byDefinition).map((space) => spaceDef(locator, space)),
782
+ notify: map(definitions.notify, (rule) => notifyDef(locator, rule)),
783
+ status: definitions.status && statusDef(locator, definitions.status, config)
784
+ };
785
+ return {
786
+ ...manifest,
787
+ version: versionOf(manifest)
788
+ };
789
+ }
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);
814
+ }
815
+ //#endregion
816
+ //#region src/build/codegen.ts
817
+ const OUT_DIR = ".pult";
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}`)}),`);
821
+ return [
822
+ "// Generated by `pult build`. Commit this file; don't edit it.",
823
+ "import type { Manifest, Project } from \"@lantharos/pult\"",
824
+ "import manifest from \"./manifest.json\" with { type: \"json\" }",
825
+ "",
826
+ `const project: Project<${component}> = {`,
827
+ ` manifest: manifest as Manifest<${component}>,`,
828
+ " modules: {",
829
+ ...entries,
830
+ " },",
831
+ "}",
832
+ "",
833
+ "export default project",
834
+ ""
835
+ ].join("\n");
836
+ }
837
+ async function readManifest(root) {
838
+ try {
839
+ return JSON.parse(await readFile(join(root, OUT_DIR, "manifest.json"), "utf8"));
840
+ } catch {
841
+ return null;
842
+ }
843
+ }
844
+ async function writeOutput(root, manifest, files) {
845
+ const dir = join(root, OUT_DIR);
846
+ await mkdir(dir, { recursive: true });
847
+ await writeFile(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
848
+ await writeFile(join(dir, "index.ts"), projectModule(manifest, files));
849
+ }
850
+ //#endregion
851
+ //#region src/build/index.ts
852
+ async function build(root, importer) {
853
+ const project = await loadProject(root, importer);
854
+ const definitions = collect(project.modules);
855
+ const manifest = assemble(root, definitions, project.config);
856
+ const problems = [...check(manifest, project.impurities), ...await checkDefaults(definitions, manifest)];
857
+ return {
858
+ manifest,
859
+ files: project.files,
860
+ problems
861
+ };
862
+ }
863
+ async function checkDefaults(definitions, manifest) {
864
+ const problems = [];
865
+ for (const [name, flag] of definitions.flags) {
866
+ if (!flag.options.value) continue;
867
+ await validate(flag.options.value, flag.options.default).catch((error) => problems.push({
868
+ source: manifest.flags[name].source,
869
+ message: `flag ${name} defaults to an invalid value: ${error.message}`
870
+ }));
871
+ }
872
+ return problems;
873
+ }
874
+ //#endregion
875
+ //#region src/cli/output.ts
876
+ const dim = (text) => styleText("dim", text);
877
+ const bold = (text) => styleText("bold", text);
878
+ function fail(message) {
879
+ console.error(`${styleText("red", "✕")} ${message}`);
880
+ process.exit(1);
881
+ }
882
+ function done(message) {
883
+ console.log(`${styleText("green", "✓")} ${message}`);
884
+ }
885
+ function printProblems(problems) {
886
+ for (const { source, message } of problems) console.error(`${styleText("red", "✕")} ${dim(source)} ${message}`);
887
+ }
888
+ const MARK = {
889
+ add: ["+", "green"],
890
+ remove: ["-", "red"],
891
+ change: ["~", "yellow"],
892
+ grant: ["▲", ["bold", "magenta"]],
893
+ revoke: ["▼", "magenta"]
894
+ };
895
+ function printChanges(changes) {
896
+ if (!changes.length) return console.log(dim(" no changes"));
897
+ for (const change of changes) {
898
+ const [mark, style] = MARK[change.kind];
899
+ console.log(` ${styleText(style, mark)} ${change.text}`);
900
+ }
901
+ }
902
+ //#endregion
903
+ //#region src/cli/project.ts
904
+ const RUNTIME_MODULES = [
905
+ "cloudflare:workers",
906
+ "cloudflare:email",
907
+ "cloudflare:sockets"
908
+ ];
909
+ const STUB = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "../build/stub.ts" : "./stub.mjs", import.meta.url));
910
+ const importer = await (async () => {
911
+ if (typeof Bun !== "undefined") {
912
+ const exports = await import(STUB);
913
+ Bun.plugin({
914
+ name: "cloudflare-runtime",
915
+ setup(build) {
916
+ for (const name of RUNTIME_MODULES) build.module(name, () => ({
917
+ exports,
918
+ loader: "object"
919
+ }));
920
+ }
921
+ });
922
+ return (file) => import(file);
923
+ }
924
+ const { createJiti } = await import("jiti");
925
+ const alias = Object.fromEntries(RUNTIME_MODULES.map((name) => [name, STUB]));
926
+ const jiti = createJiti(import.meta.url, { alias });
927
+ return (file) => jiti.import(file);
928
+ })();
929
+ async function buildProject(root) {
930
+ try {
931
+ return await build(root, importer);
932
+ } catch (error) {
933
+ fail(error.message);
934
+ }
935
+ }
936
+ var Remote = class {
937
+ #transport;
938
+ constructor() {
939
+ const key = process.env.PULT_KEY;
940
+ if (!key) fail("Set PULT_KEY to the key of the environment you're targeting");
941
+ this.#transport = new Transport(key, process.env.PULT_URL ?? "https://app.pult.sh", fetch);
942
+ }
943
+ async #request(method, path, body) {
944
+ try {
945
+ return await this.#transport.request(method, path, body);
946
+ } catch (error) {
947
+ fail(error.message);
948
+ }
949
+ }
950
+ current() {
951
+ return this.#request("GET", "/v1/manifest");
952
+ }
953
+ deploy(manifest) {
954
+ return this.#request("POST", "/v1/manifest", manifest);
955
+ }
956
+ };
957
+ //#endregion
958
+ //#region src/cli/commands/build.ts
959
+ async function buildCommand(root) {
960
+ const { manifest, files, problems } = await buildProject(root);
961
+ if (problems.length) {
962
+ printProblems(problems);
963
+ fail(`${problems.length} problem${problems.length === 1 ? "" : "s"}, manifest not written`);
964
+ }
965
+ await writeOutput(root, manifest, files);
966
+ done(`manifest ${manifest.version} · ${files.length} files`);
967
+ return manifest;
968
+ }
969
+ //#endregion
970
+ //#region src/cli/commands/check.ts
971
+ async function checkCommand(root) {
972
+ const { manifest, problems } = await buildProject(root);
973
+ printProblems(problems);
974
+ if (problems.length) fail(`${problems.length} problem${problems.length === 1 ? "" : "s"}`);
975
+ if (stableJson(await readManifest(root)) !== stableJson(manifest)) fail("The committed manifest is out of date. Run `pult build`.");
976
+ done(`manifest ${manifest.version} is valid and up to date`);
977
+ }
978
+ //#endregion
979
+ //#region src/cli/commands/deploy.ts
980
+ async function deployCommand(root) {
981
+ const manifest = await buildCommand(root);
982
+ const remote = new Remote();
983
+ const current = await remote.current();
984
+ if (current.manifest?.version === manifest.version) return done(`${current.name} is already on ${manifest.version}`);
985
+ console.log(`${bold(current.name)} ${dim(`${current.manifest?.version ?? "empty"} → ${manifest.version}`)}`);
986
+ printChanges(diffManifests(current.manifest, manifest));
987
+ await remote.deploy(manifest);
988
+ done(`deployed ${manifest.version} to ${current.name}`);
989
+ }
990
+ //#endregion
991
+ //#region src/cli/commands/dev.ts
992
+ const IGNORED = /(^|[\\/])(node_modules|\.pult|\.git|dist|build)([\\/]|$)/;
993
+ function devCommand(root) {
994
+ let running = false;
995
+ let queued = false;
996
+ const rebuild = () => {
997
+ if (running) {
998
+ queued = true;
999
+ return;
1000
+ }
1001
+ running = true;
1002
+ spawn(process.execPath, [
1003
+ ...process.execArgv,
1004
+ process.argv[1],
1005
+ "build"
1006
+ ], {
1007
+ cwd: root,
1008
+ stdio: "inherit"
1009
+ }).on("exit", () => {
1010
+ running = false;
1011
+ if (queued) {
1012
+ queued = false;
1013
+ rebuild();
1014
+ }
1015
+ });
1016
+ };
1017
+ let timer;
1018
+ watch(root, { recursive: true }, (_, file) => {
1019
+ if (!file || IGNORED.test(file) || !/\.(m?[jt]s)$/.test(file)) return;
1020
+ clearTimeout(timer);
1021
+ timer = setTimeout(rebuild, 80);
1022
+ });
1023
+ console.log(dim("watching for changes"));
1024
+ rebuild();
1025
+ }
1026
+ //#endregion
1027
+ //#region src/cli/commands/diff.ts
1028
+ async function diffCommand(root) {
1029
+ const { manifest, problems } = await buildProject(root);
1030
+ if (problems.length) {
1031
+ printProblems(problems);
1032
+ fail("Fix the problems above first");
1033
+ }
1034
+ const current = await new Remote().current();
1035
+ console.log(`${bold(current.name)} ${dim(`${current.manifest?.version ?? "empty"} → ${manifest.version}`)}`);
1036
+ printChanges(diffManifests(current.manifest, manifest));
1037
+ }
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
1101
+ //#region src/cli/index.ts
1102
+ const USAGE = `pult <command> [--cwd <path>]
1103
+
1104
+ init set up Pult in this project
1105
+ build compile definitions into .pult/
1106
+ check find broken references and make sure .pult/ is current
1107
+ diff show what a deploy would change (uses PULT_KEY)
1108
+ deploy push the manifest to the environment behind PULT_KEY
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`;
1114
+ const { positionals, values } = parseArgs({
1115
+ allowPositionals: true,
1116
+ options: {
1117
+ cwd: { type: "string" },
1118
+ help: {
1119
+ type: "boolean",
1120
+ short: "h"
1121
+ },
1122
+ version: {
1123
+ type: "boolean",
1124
+ short: "v"
1125
+ }
1126
+ }
1127
+ });
1128
+ const root = resolve(values.cwd ?? ".");
1129
+ const commands = {
1130
+ init: initCommand,
1131
+ build: buildCommand,
1132
+ check: checkCommand,
1133
+ diff: diffCommand,
1134
+ deploy: deployCommand,
1135
+ dev: devCommand
1136
+ };
1137
+ const command = positionals[0];
1138
+ if (values.version) console.log(version);
1139
+ else if (values.help || !command) console.log(USAGE);
1140
+ else if (!commands[command]) fail(`Unknown command "${command}"\n\n${USAGE}`);
1141
+ else await commands[command](root);
1142
+ //#endregion
1143
+ export {};