@lantharos/pult 0.8.0 → 0.10.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.10.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,16 +274,27 @@ 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}"`
185
295
  });
186
296
  for (const [key, action] of Object.entries(resource.actions)) {
297
+ if (action.moves) checkMoves(manifest, name, key, action.moves, action.source, problems);
187
298
  const inputs = action.input?.properties ?? {};
188
299
  for (const field of [...Object.keys(action.pick), ...action.choices]) if (!inputs[field]) problems.push({
189
300
  source: action.source,
@@ -228,8 +339,14 @@ function checkManifest(manifest) {
228
339
  source: inbox.source,
229
340
  message: `triage runs ${decision.action}, which needs a human (danger "${def.danger}"). Set automatable: true on the action to allow it.`
230
341
  });
342
+ const missing = (def?.input?.required ?? []).filter((field) => !(field in (decision.input ?? {})));
343
+ if (missing.length) problems.push({
344
+ source: inbox.source,
345
+ message: `triage runs ${decision.action} without ${missing.join(", ")}, which it needs as input`
346
+ });
231
347
  }
232
348
  }
349
+ checkSpaces(manifest, problems);
233
350
  for (const view of Object.values(manifest.views)) {
234
351
  const inbox = manifest.inboxes[view.inbox];
235
352
  checkColumns(view.source, inbox, view.columns, problems);
@@ -240,6 +357,17 @@ function checkManifest(manifest) {
240
357
  }
241
358
  return problems;
242
359
  }
360
+ function checkMoves(manifest, resource, key, state, source, problems) {
361
+ const inboxes = Object.values(manifest.inboxes).filter((inbox) => inbox.resource === resource);
362
+ if (!inboxes.length) problems.push({
363
+ source,
364
+ message: `${resource}.${key} moves items, but no inbox is about ${resource}`
365
+ });
366
+ else if (!inboxes.some((inbox) => inbox.states.includes(state))) problems.push({
367
+ source,
368
+ message: `${resource}.${key} moves items to "${state}", which no inbox about ${resource} has`
369
+ });
370
+ }
243
371
  function checkSchedule(name, action, problems) {
244
372
  const { cron, timezone } = action.schedule;
245
373
  try {
@@ -295,16 +423,23 @@ function checkBlocks(manifest, source, owner, blocks, problems) {
295
423
  const action = (id) => {
296
424
  if (!findActionDef(manifest, id)) problem(`uses unknown action ${id}`);
297
425
  };
426
+ const resource = (name) => {
427
+ if (!manifest.resources[name]) problem(`shows unknown resource ${name}`);
428
+ };
429
+ const value = (field) => {
430
+ if (field && typeof field === "object" && "edit" in field) action(field.edit.action);
431
+ const shown = shownValue(field);
432
+ if (shown && typeof shown === "object" && "record" in shown) resource(shown.record.resource);
433
+ };
298
434
  eachBlock(blocks, (block) => {
299
435
  if (block.type === "actions") for (const button of block.actions) action(button.action);
300
436
  if (block.type === "form") action(block.form.action);
437
+ if (block.type === "record") resource(block.ref.resource);
301
438
  if (block.type === "flag" && !manifest.flags[block.flag]) problem(`shows unknown flag ${block.flag}`);
302
439
  if ((block.type === "count" || block.type === "items" || block.type === "chart") && "inbox" in block) {
303
440
  if (!manifest.inboxes[block.inbox]) problem(`shows unknown inbox ${block.inbox}`);
304
441
  }
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
- }
442
+ for (const field of valuesIn(block)) value(field);
308
443
  });
309
444
  }
310
445
  //#endregion
@@ -328,7 +463,10 @@ function findConfig(root) {
328
463
  if (!file) throw new Error(`No pult.config.ts in ${root}`);
329
464
  return file;
330
465
  }
331
- async function loadProject(root, importer) {
466
+ function loadProject(root, importer) {
467
+ return traceOrigins(() => load(root, importer));
468
+ }
469
+ async function load(root, importer) {
332
470
  const config = (await importer(findConfig(root))).default;
333
471
  if (kindOf(config) !== "config") throw new Error("pult.config.ts must default-export defineConfig({ ... })");
334
472
  const { include } = config.options;
@@ -419,10 +557,6 @@ var Locator = class {
419
557
  return this.locate(origin, new RegExp(`\\b${kind}\\(\\s*["'\`]${escapeRegExp(name)}["'\`]`));
420
558
  }
421
559
  };
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
560
  function pluralize(label) {
427
561
  return /(s|x|sh|ch)$/.test(label) ? `${label}es` : /[^aeiou]y$/.test(label) ? `${label.slice(0, -1)}ies` : `${label}s`;
428
562
  }
@@ -439,6 +573,7 @@ function actionDef(source, key, action) {
439
573
  approval: options.approval ?? false,
440
574
  contextual: Boolean(options.when || options.defaults),
441
575
  background: options.background ?? false,
576
+ moves: options.moves ?? null,
442
577
  pick: options.pick ?? {},
443
578
  choices: Object.keys(options.choices ?? {}),
444
579
  schedule: options.schedule ? schedule(options.schedule) : null
@@ -551,7 +686,7 @@ function pageDef(locator, page) {
551
686
  };
552
687
  }
553
688
  function staticBlocks(page, blocks) {
554
- if (blocks.some(isPendingAttachment)) throw new Error(`Page "${page}" attaches file data. Render it from a function instead.`);
689
+ if (hasPendingAttachments(blocks)) throw new Error(`Page "${page}" attaches file data. Render it from a function instead.`);
555
690
  return JSON.parse(JSON.stringify(blocks));
556
691
  }
557
692
  function flagDef(locator, flag) {
@@ -573,14 +708,34 @@ function flagDef(locator, flag) {
573
708
  approval: options.approval ?? false
574
709
  };
575
710
  }
711
+ function spaceDef(locator, space) {
712
+ const { options } = space;
713
+ return {
714
+ name: space.name,
715
+ source: locator.call("space", space.origin, space.name),
716
+ label: options.label ?? humanize(space.name),
717
+ icon: options.icon ?? "layout",
718
+ home: space.home?.name ?? null,
719
+ inboxes: (options.inboxes ?? []).map((inbox) => inbox.name),
720
+ resources: (options.resources ?? []).map((resource) => resource.name),
721
+ pages: (options.pages ?? []).map((page) => page.name),
722
+ flags: (options.flags ?? []).map((flag) => flag.name)
723
+ };
724
+ }
725
+ function byDefinition(a, b) {
726
+ return a.origin.file.localeCompare(b.origin.file) || a.origin.line - b.origin.line;
727
+ }
576
728
  function notifyDef(locator, rule) {
577
729
  return {
578
730
  source: locator.call("notify", rule.origin, rule.name),
579
731
  inboxes: rule.options.inboxes.map((inbox) => inbox.name),
580
732
  on: rule.options.on,
581
- to: rule.options.to.map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name })
733
+ to: recipients(rule.options.to)
582
734
  };
583
735
  }
736
+ function recipients(list) {
737
+ return list.map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name });
738
+ }
584
739
  function logo(value) {
585
740
  if (value === void 0) return null;
586
741
  return typeof value === "string" ? {
@@ -606,7 +761,7 @@ function statusDef(locator, page, config) {
606
761
  monitored: component.monitored ?? false,
607
762
  check: component.check ?? null
608
763
  }])),
609
- alert: (page.options.alert ?? []).map((recipient) => "channel" in recipient ? { channel: recipient.channel } : { role: recipient.name }),
764
+ alert: recipients(page.options.alert ?? []),
610
765
  after: milliseconds(page.options.after ?? "2m")
611
766
  };
612
767
  }
@@ -636,32 +791,59 @@ function assemble(root, definitions, config) {
636
791
  pages: map(definitions.pages, (page) => pageDef(locator, page)),
637
792
  flags: map(definitions.flags, (flag) => flagDef(locator, flag)),
638
793
  roles,
794
+ spaces: [...definitions.spaces.values()].sort(byDefinition).map((space) => spaceDef(locator, space)),
639
795
  notify: map(definitions.notify, (rule) => notifyDef(locator, rule)),
640
796
  status: definitions.status && statusDef(locator, definitions.status, config)
641
797
  };
642
798
  return {
643
799
  ...manifest,
644
- version: hash(stableJson(manifest))
800
+ version: versionOf(manifest)
645
801
  };
646
802
  }
647
- function hash(text) {
648
- return createHash("sha256").update(text).digest("hex").slice(0, 12);
803
+ function unsourced(definitions) {
804
+ return Object.fromEntries(Object.entries(definitions).map(([name, { source: _, ...rest }]) => [name, rest]));
805
+ }
806
+ function versionOf(manifest) {
807
+ const content = {
808
+ ...manifest,
809
+ resources: Object.fromEntries(Object.entries(manifest.resources).map(([name, { source: _, actions, ...rest }]) => [name, {
810
+ ...rest,
811
+ actions: unsourced(actions)
812
+ }])),
813
+ actions: unsourced(manifest.actions),
814
+ inboxes: unsourced(manifest.inboxes),
815
+ views: unsourced(manifest.views),
816
+ pages: unsourced(manifest.pages),
817
+ flags: unsourced(manifest.flags),
818
+ roles: unsourced(manifest.roles),
819
+ spaces: manifest.spaces.map(({ source: _, ...rest }) => rest),
820
+ notify: unsourced(manifest.notify),
821
+ status: manifest.status && {
822
+ ...manifest.status,
823
+ source: void 0
824
+ }
825
+ };
826
+ return createHash("sha256").update(stableJson(content)).digest("hex").slice(0, 12);
649
827
  }
650
828
  //#endregion
651
829
  //#region src/build/codegen.ts
652
830
  const OUT_DIR = ".pult";
653
- function projectModule(files) {
831
+ function projectModule(manifest, files) {
832
+ const component = Object.keys(manifest.status?.components ?? {}).map((name) => JSON.stringify(name)).join(" | ") || "never";
833
+ const entries = files.map((file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(`../${file}`)}),`);
654
834
  return [
655
835
  "// Generated by `pult build`. Commit this file; don't edit it.",
656
- "import type { Manifest, Project } from \"pult\"",
836
+ "import type { Manifest, Project } from \"@lantharos/pult\"",
657
837
  "import manifest from \"./manifest.json\" with { type: \"json\" }",
658
838
  "",
659
- "export default {",
660
- " manifest: manifest as Manifest,",
839
+ `const project: Project<${component}> = {`,
840
+ ` manifest: manifest as Manifest<${component}>,`,
661
841
  " modules: {",
662
- ...files.map((file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(`../${file}`)}),`),
842
+ ...entries,
663
843
  " },",
664
- "} satisfies Project",
844
+ "}",
845
+ "",
846
+ "export default project",
665
847
  ""
666
848
  ].join("\n");
667
849
  }
@@ -676,7 +858,7 @@ async function writeOutput(root, manifest, files) {
676
858
  const dir = join(root, OUT_DIR);
677
859
  await mkdir(dir, { recursive: true });
678
860
  await writeFile(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
679
- await writeFile(join(dir, "index.ts"), projectModule(files));
861
+ await writeFile(join(dir, "index.ts"), projectModule(manifest, files));
680
862
  }
681
863
  //#endregion
682
864
  //#region src/build/index.ts
@@ -765,34 +947,18 @@ async function buildProject(root) {
765
947
  }
766
948
  }
767
949
  var Remote = class {
768
- environment;
769
- #secret;
770
- #url;
950
+ #transport;
771
951
  constructor() {
772
952
  const key = process.env.PULT_KEY;
773
953
  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(/\/$/, "");
954
+ this.#transport = new Transport(key, process.env.PULT_URL ?? "https://app.pult.sh", fetch);
778
955
  }
779
956
  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;
957
+ try {
958
+ return await this.#transport.request(method, path, body);
959
+ } catch (error) {
960
+ fail(error.message);
961
+ }
796
962
  }
797
963
  current() {
798
964
  return this.#request("GET", "/v1/manifest");
@@ -819,7 +985,7 @@ async function checkCommand(root) {
819
985
  const { manifest, problems } = await buildProject(root);
820
986
  printProblems(problems);
821
987
  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`.");
988
+ if (stableJson(await readManifest(root)) !== stableJson(manifest)) fail("The committed manifest is out of date. Run `pult build`.");
823
989
  done(`manifest ${manifest.version} is valid and up to date`);
824
990
  }
825
991
  //#endregion
@@ -883,14 +1049,81 @@ async function diffCommand(root) {
883
1049
  printChanges(diffManifests(current.manifest, manifest));
884
1050
  }
885
1051
  //#endregion
1052
+ //#region src/cli/commands/init.ts
1053
+ const WRANGLER_FILES = [
1054
+ "wrangler.jsonc",
1055
+ "wrangler.json",
1056
+ "wrangler.toml"
1057
+ ];
1058
+ const WEBHOOK_PATH = "/pult";
1059
+ function config(project, onWorkers) {
1060
+ return `import { defineConfig } from "@lantharos/pult"
1061
+
1062
+ export default defineConfig({
1063
+ project: ${JSON.stringify(project)},
1064
+ include: ["pult/**/*.ts"],${onWorkers ? `\n delivery: { mode: "webhook", path: "${WEBHOOK_PATH}" },` : ""}
1065
+ })
1066
+ `;
1067
+ }
1068
+ const DEFINITIONS = `import { inbox, resource } from "@lantharos/pult"
1069
+
1070
+ export const user = resource("user", {
1071
+ get: (id) => ({ id, email: \`\${id}@example.com\` }),
1072
+ title: (user) => user.email,
1073
+ })
1074
+
1075
+ export const feedback = inbox("feedback", { resource: user })
1076
+ `;
1077
+ const SOCKET_CLIENT = `import { createPult } from "@lantharos/pult"
1078
+ import project from "../.pult"
1079
+
1080
+ export const pult = createPult({ project })
1081
+
1082
+ pult.listen()
1083
+ `;
1084
+ const WORKER_CLIENT = `import { env } from "cloudflare:workers"
1085
+ import { createPult } from "@lantharos/pult"
1086
+ import project from "../.pult"
1087
+
1088
+ export const pult = createPult({ key: env.PULT_KEY, project })
1089
+ export const handlePult = pult.handler()
1090
+ `;
1091
+ async function projectName(root) {
1092
+ 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";
1093
+ }
1094
+ async function create(root, file, content) {
1095
+ const path = join(root, file);
1096
+ await mkdir(dirname(path), { recursive: true });
1097
+ try {
1098
+ await writeFile(path, content, { flag: "wx" });
1099
+ done(`wrote ${file}`);
1100
+ } catch (error) {
1101
+ if (error.code !== "EEXIST") throw error;
1102
+ console.log(dim(` kept ${file}, it already exists`));
1103
+ }
1104
+ }
1105
+ async function initCommand(root) {
1106
+ const onWorkers = WRANGLER_FILES.some((file) => existsSync(join(root, file)));
1107
+ await create(root, "pult.config.ts", config(await projectName(root), onWorkers));
1108
+ await create(root, "pult/feedback.ts", DEFINITIONS);
1109
+ await create(root, "src/pult.ts", onWorkers ? WORKER_CLIENT : SOCKET_CLIENT);
1110
+ console.log(`\nNext, run ${bold("pult build")} and set PULT_KEY to your environment's key.`);
1111
+ 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)`);
1112
+ }
1113
+ //#endregion
886
1114
  //#region src/cli/index.ts
887
- const USAGE = `pult <command>
1115
+ const USAGE = `pult <command> [--cwd <path>]
888
1116
 
1117
+ init set up Pult in this project
889
1118
  build compile definitions into .pult/
890
- check type-check references and make sure the manifest is current
1119
+ check find broken references and make sure .pult/ is current
891
1120
  diff show what a deploy would change (uses PULT_KEY)
892
1121
  deploy push the manifest to the environment behind PULT_KEY
893
- dev rebuild on every change`;
1122
+ dev rebuild on every change
1123
+
1124
+ --cwd <path> run in another project folder
1125
+ -v, --version print the version
1126
+ -h, --help show this help`;
894
1127
  const { positionals, values } = parseArgs({
895
1128
  allowPositionals: true,
896
1129
  options: {
@@ -898,11 +1131,16 @@ const { positionals, values } = parseArgs({
898
1131
  help: {
899
1132
  type: "boolean",
900
1133
  short: "h"
1134
+ },
1135
+ version: {
1136
+ type: "boolean",
1137
+ short: "v"
901
1138
  }
902
1139
  }
903
1140
  });
904
- const root = values.cwd ?? process.cwd();
1141
+ const root = resolve(values.cwd ?? ".");
905
1142
  const commands = {
1143
+ init: initCommand,
906
1144
  build: buildCommand,
907
1145
  check: checkCommand,
908
1146
  diff: diffCommand,
@@ -910,7 +1148,8 @@ const commands = {
910
1148
  dev: devCommand
911
1149
  };
912
1150
  const command = positionals[0];
913
- if (values.help || !command) console.log(USAGE);
1151
+ if (values.version) console.log(version);
1152
+ else if (values.help || !command) console.log(USAGE);
914
1153
  else if (!commands[command]) fail(`Unknown command "${command}"\n\n${USAGE}`);
915
1154
  else await commands[command](root);
916
1155
  //#endregion