@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/cli.mjs CHANGED
@@ -1,16 +1,16 @@
1
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";
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, inner, prefix = "") {
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
- const nested = inner?.(name, a, b) ?? [];
28
- if (nested.length) changes.push(...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 stableJson(a, ["source"]) === stableJson(b, ["source"]);
35
+ function sameShape(a, b, covers = []) {
36
+ return shape(a, covers) === shape(b, covers);
38
37
  }
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
- });
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, (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];
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, (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
- }] : []),
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, (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
- })
127
+ ...keyed("role", base.roles, after.roles, roleChanges)
120
128
  ];
121
129
  }
122
- function actionChanges(prefix) {
123
- return (name, a, b) => {
124
- const action = `action ${prefix}${name}`;
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.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
- }] : []
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)) if (flag.target && !manifest.resources[flag.target]) problems.push({
178
- source: flag.source,
179
- message: `flag ${name} targets unknown resource "${flag.target}"`
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
- if (block.type === "fields") {
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
- async function loadProject(root, importer) {
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.some(isPendingAttachment)) throw new Error(`Page "${page}" attaches file data. Render it from a function instead.`);
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.map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name })
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 ?? []).map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name }),
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: hash(stableJson(manifest))
787
+ version: versionOf(manifest)
645
788
  };
646
789
  }
647
- function hash(text) {
648
- return createHash("sha256").update(text).digest("hex").slice(0, 12);
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
- "export default {",
660
- " manifest: manifest as Manifest,",
826
+ `const project: Project<${component}> = {`,
827
+ ` manifest: manifest as Manifest<${component}>,`,
661
828
  " modules: {",
662
- ...files.map((file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(`../${file}`)}),`),
829
+ ...entries,
663
830
  " },",
664
- "} satisfies Project",
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
- environment;
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
- 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(/\/$/, "");
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
- 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;
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))?.version !== manifest.version) fail("The committed manifest is out of date. Run `pult build`.");
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 type-check references and make sure the manifest is current
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 ?? process.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.help || !command) console.log(USAGE);
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