@trypattern/sdk 0.1.12 → 0.1.14

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.
Files changed (84) hide show
  1. package/README.md +73 -12
  2. package/dist/access.d.ts +0 -4
  3. package/dist/access.js +0 -5
  4. package/dist/api.d.ts +3 -1
  5. package/dist/api.js +5 -23
  6. package/dist/app.d.ts +20 -5
  7. package/dist/app.js +32 -2
  8. package/dist/blocks.d.ts +18 -0
  9. package/dist/blocks.js +36 -0
  10. package/dist/bookings.d.ts +400 -0
  11. package/dist/bookings.js +300 -0
  12. package/dist/bookingsMemory.d.ts +9 -0
  13. package/dist/bookingsMemory.js +79 -0
  14. package/dist/bookingsStore.d.ts +698 -0
  15. package/dist/bookingsStore.js +129 -0
  16. package/dist/cli/app.js +144 -59
  17. package/dist/cli/publishing.d.ts +3 -1
  18. package/dist/cli/publishing.js +45 -10
  19. package/dist/cli/starter.d.ts +17 -0
  20. package/dist/cli/starter.js +140 -0
  21. package/dist/components/Calendar.d.ts +20 -0
  22. package/dist/components/Calendar.js +195 -0
  23. package/dist/components/Card.d.ts +1 -1
  24. package/dist/components/Card.js +7 -29
  25. package/dist/components/Choice.js +27 -13
  26. package/dist/components/ChoiceGroup.d.ts +3 -1
  27. package/dist/components/ChoiceGroup.js +3 -4
  28. package/dist/components/Dialog.d.ts +1 -1
  29. package/dist/components/Dialog.js +3 -1
  30. package/dist/components/ListRow.js +2 -1
  31. package/dist/components/RadioDescribed.d.ts +4 -0
  32. package/dist/components/RadioDescribed.js +20 -0
  33. package/dist/components/RadioGroup.d.ts +4 -1
  34. package/dist/components/RadioGroup.js +28 -3
  35. package/dist/components/Text.d.ts +1 -1
  36. package/dist/components/Text.js +43 -2
  37. package/dist/components/calendarDates.d.ts +14 -0
  38. package/dist/components/calendarDates.js +84 -0
  39. package/dist/components/controlLook.js +11 -11
  40. package/dist/components/icons.d.ts +2 -0
  41. package/dist/components/icons.js +2 -0
  42. package/dist/components/interaction.d.ts +1 -1
  43. package/dist/flows/browser.d.ts +7 -1
  44. package/dist/flows/browser.js +25 -7
  45. package/dist/flows/flowBrowser.d.ts +2 -2
  46. package/dist/flows/flowBrowser.js +2 -2
  47. package/dist/flows/flows.js +8 -2
  48. package/dist/index.d.ts +4 -1
  49. package/dist/index.js +1 -0
  50. package/dist/media.d.ts +11 -1
  51. package/dist/patternAccess.d.ts +11 -2
  52. package/dist/patternAccess.js +8 -4
  53. package/dist/publishWire.d.ts +35 -2
  54. package/dist/publishWire.js +26 -3
  55. package/dist/react.d.ts +2 -0
  56. package/dist/react.js +1 -0
  57. package/dist/rules/checkAllFiles.js +16 -2
  58. package/dist/rules/lintPlugin.js +1 -0
  59. package/dist/schema.d.ts +3 -1
  60. package/dist/schema.js +1 -0
  61. package/dist/server/bookings.d.ts +12 -0
  62. package/dist/server/bookings.js +142 -0
  63. package/dist/server/createServer.d.ts +3 -2
  64. package/dist/server/createServer.js +13 -4
  65. package/dist/server/env.d.ts +8 -0
  66. package/dist/server/index.d.ts +3 -1
  67. package/dist/server/index.js +1 -0
  68. package/dist/server/media.d.ts +16 -3
  69. package/dist/server/media.js +72 -23
  70. package/dist/server/patternViewer.js +16 -10
  71. package/dist/server/tables.d.ts +2 -0
  72. package/dist/server/tables.js +17 -1
  73. package/dist/start.js +12 -0
  74. package/dist/table.js +9 -3
  75. package/dist/theme/scales.d.ts +1 -1
  76. package/dist/theme/scales.js +1 -0
  77. package/dist/theme/tokens.stylex.d.ts +18 -0
  78. package/dist/theme/tokens.stylex.js +19 -1
  79. package/dist/vite/localWorker.d.ts +2 -0
  80. package/dist/vite/localWorker.js +48 -3
  81. package/dist/vite/projectFile.d.ts +5 -0
  82. package/dist/vite/projectFile.js +19 -1
  83. package/dist/vite/vite.js +5 -1
  84. package/package.json +1 -1
@@ -0,0 +1,129 @@
1
+ import { z } from 'zod';
2
+ import { bookingDay, bookingRequests, bookingRulesReply, bookingServices, bookingSlot, bookingTables } from "./bookings.js";
3
+ import { bookingMemory } from "./bookingsMemory.js";
4
+ import { ServiceRefused } from "./services.js";
5
+ import { defineStore } from "./store.js";
6
+ const Store = defineStore();
7
+ const OUTCOMES = {
8
+ 401: 'forbidden',
9
+ 403: 'forbidden',
10
+ 404: 'missing',
11
+ 409: 'conflict'
12
+ };
13
+ function outcomeOf(error) {
14
+ return error instanceof ServiceRefused
15
+ ? (OUTCOMES[error.status] ?? 'failed')
16
+ : 'failed';
17
+ }
18
+ const loadStatus = z.enum(['idle', 'loading', 'ready', 'failed']);
19
+ export class Bookings extends Store({
20
+ state: {
21
+ days: z.record(z.string(), z.array(bookingDay)).default({}),
22
+ windows: z.record(z.string(), z.array(bookingSlot)).default({}),
23
+ requests: z.array(bookingRequests.row).default([]),
24
+ requestsStatus: loadStatus.default('idle'),
25
+ rules: bookingRulesReply.nullable().default(null),
26
+ sent: bookingRequests.row.nullable().default(null),
27
+ isSending: z.boolean().default(false)
28
+ }
29
+ }) {
30
+ async loadDays(month) {
31
+ try {
32
+ const days = await this.services.bookings.days({ month });
33
+ this.set({ days: { ...this.state.days, [month]: days } });
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ }
39
+ async loadWindows(date) {
40
+ try {
41
+ const windows = await this.services.bookings.windows({ date });
42
+ this.set({ windows: { ...this.state.windows, [date]: windows } });
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ }
48
+ async request(fields) {
49
+ this.set({ isSending: true });
50
+ try {
51
+ const sent = await this.services.bookings.request(fields);
52
+ this.set({ sent });
53
+ return 'saved';
54
+ }
55
+ catch (error) {
56
+ return outcomeOf(error);
57
+ }
58
+ finally {
59
+ this.set({ isSending: false });
60
+ await Promise.all([
61
+ this.loadDays(fields.date.slice(0, 7)),
62
+ this.loadWindows(fields.date)
63
+ ]);
64
+ }
65
+ }
66
+ forgetSent() {
67
+ this.set({ sent: null });
68
+ }
69
+ async loadRequests() {
70
+ if (this.state.requestsStatus !== 'ready') {
71
+ this.set({ requestsStatus: 'loading' });
72
+ }
73
+ try {
74
+ const requests = await this.services.bookings.requests();
75
+ this.set({ requests, requestsStatus: 'ready' });
76
+ }
77
+ catch {
78
+ if (this.state.requestsStatus === 'loading') {
79
+ this.set({ requestsStatus: 'failed' });
80
+ }
81
+ }
82
+ }
83
+ accept(id) {
84
+ return this.#decide(id, () => this.services.bookings.accept({ id }));
85
+ }
86
+ decline(id) {
87
+ return this.#decide(id, () => this.services.bookings.decline({ id }));
88
+ }
89
+ async loadRules() {
90
+ try {
91
+ this.set({ rules: await this.services.bookings.rules() });
92
+ }
93
+ catch {
94
+ return;
95
+ }
96
+ }
97
+ async saveRules(rules) {
98
+ try {
99
+ this.set({
100
+ rules: await this.services.bookings.saveRules(rules),
101
+ days: {},
102
+ windows: {}
103
+ });
104
+ return 'saved';
105
+ }
106
+ catch (error) {
107
+ return outcomeOf(error);
108
+ }
109
+ }
110
+ async #decide(id, send) {
111
+ try {
112
+ const decided = await send();
113
+ this.set({
114
+ requests: this.state.requests.map((request) => request.id === id ? decided : request)
115
+ });
116
+ return 'saved';
117
+ }
118
+ catch (error) {
119
+ return outcomeOf(error);
120
+ }
121
+ }
122
+ }
123
+ export const bookings = {
124
+ name: 'bookings',
125
+ tables: bookingTables,
126
+ services: bookingServices,
127
+ memory: bookingMemory,
128
+ Store: Bookings
129
+ };
package/dist/cli/app.js CHANGED
@@ -16,8 +16,10 @@ import { createHash } from 'node:crypto';
16
16
  import { z } from 'zod';
17
17
  import { MEDIA_LIMIT_BYTES } from "../media.js";
18
18
  import { isHeic } from "../server/imageHeader.js";
19
- import { resetLocalDatabase } from "../vite/localWorker.js";
20
- import { login, publish, pull, restore, versions as listVersions } from "./publishing.js";
19
+ import { putLocalPhotos, resetLocalDatabase } from "../vite/localWorker.js";
20
+ import { readProjectFile } from "../vite/projectFile.js";
21
+ import { readStarter, starterContentOf, withStarter } from "./starter.js";
22
+ import { addLiveMedia, login, publish, pull, restore, versions as listVersions } from "./publishing.js";
21
23
  import { toolBin } from "./tools.js";
22
24
  function dependsOnPattern(directory) {
23
25
  try {
@@ -93,7 +95,7 @@ const PHOTO_TYPES = {
93
95
  '.avif': 'image/avif'
94
96
  };
95
97
  const HEIC_EXTENSIONS = new Set(['.heic', '.heif']);
96
- const MEDIA_USAGE = ' usage app media add photo.jpg --alt "Pink peonies in a glass vase"';
98
+ const MEDIA_USAGE = ' usage app media add photo.jpg --alt "Pink peonies in a glass vase" [--live]';
97
99
  const SHIM_SOURCE = `#!/bin/sh
98
100
  ${SHIM_MARKER_LINE}
99
101
  dir=$PWD
@@ -193,10 +195,11 @@ app check format and app check lint pass for anything a tool can fix by itself.`
193
195
  run: fix
194
196
  },
195
197
  reset: {
196
- summary: 'Empty the local database and fill it again from seed.ts',
198
+ summary: 'Empty the local database and fill it again from seed.ts and starter/',
197
199
  details: `Deletes every row in every table of the local database (signed-in users stay) and inserts
198
- the rows in seed.ts again. app start only fills an empty table, so run this after changing seed.ts.
199
- Works while the dev server runs; reload the page to see the new rows.`,
200
+ the rows in seed.ts again, with the rows in starter/rows.ts and their photos from starter/photos,
201
+ which go into the local MEDIA bucket. app start only fills an empty table, so run this after
202
+ changing seed.ts or starter/. Works while the dev server runs; reload the page to see the new rows.`,
200
203
  run: reset
201
204
  },
202
205
  migration: {
@@ -209,12 +212,18 @@ migrations to the local database. Prints "No schema changes" when the migrations
209
212
  takesArgument: true
210
213
  },
211
214
  media: {
212
- summary: 'Add a photo to the media table: app media add photo.jpg --alt "…"',
215
+ summary: 'Add a photo to the media table: app media add photo.jpg --alt "…" [--live]',
213
216
  details: `app media add photo.jpg --alt "Pink peonies in a glass vase"
217
+ app media add photo.jpg --alt "Pink peonies in a glass vase" --live
214
218
  Sends the photo to the dev server's POST /api/media, signed in as the first email in admins in
215
219
  schema.ts (dev sign-in, so this adds to the local database and the local MEDIA bucket). The server
216
220
  reads its width and height, stores it as images/<hash>.<ext> and adds a row to the media table;
217
- this prints the row. Show it with <Image image={row} sizes="…" />.
221
+ this prints the row and its id. Show it with <Image image={row} sizes="…" />.
222
+ Without --live the photo stays on this computer, and this says so.
223
+ --live first sends it to the live site, <name>.onpattern.app, through onpattern.app (signing in
224
+ like app publish), whose own POST /api/media works out its size, placeholder and alt the same way,
225
+ then adds it to the local database too under the same id, so both show it at once. Putting it on a
226
+ live item is the owner's change to make on the live site; the local photo only shows it here.
218
227
  --alt is required: describe the photo for someone who can't see it.
219
228
  JPEG, PNG, WebP, GIF or AVIF, up to 10 MB. iPhone HEIC photos are refused: convert one first with
220
229
  sips -s format jpeg photo.heic --out photo.jpg. The app's tables must include media from
@@ -222,7 +231,7 @@ sips -s format jpeg photo.heic --out photo.jpg. The app's tables must include me
222
231
  run_worker_first. Uses the running dev server, or starts one and stops it afterwards.`,
223
232
  run: (_argument, options, rest) => addMedia(rest, options),
224
233
  argumentCount: 2,
225
- flags: ['--alt']
234
+ flags: ['--alt', '--live']
226
235
  },
227
236
  build: {
228
237
  summary: 'Build the app for production into dist/',
@@ -321,10 +330,22 @@ moves forward with any new migrations; nothing is ever deleted from it. If a ste
321
330
  keeps running the previous version and the error says why. Run app check first.
322
331
  --message '…' says what changed in a few plain words for the owner ("Pink brand colour and a welcome
323
332
  line"); onpattern.app and app versions show it beside the version.
324
- --starter (Pattern's admins only) publishes this project as the starter every new site begins from.`,
325
- run: (_argument, options) => {
333
+ --starter (Pattern's admins only) publishes this project as the starter new sites begin from: the one
334
+ for its "blueprint" in pattern.json (website or web-app), which a new business picks when it starts.
335
+ With a starter/ folder, its rows (starter/rows.ts) and photos (starter/photos) go with it, and a new
336
+ site's first publish puts them in its own database and photo bucket: the photos through the site's
337
+ own POST /api/media, then the rows. Later publishes never touch them; they are the owner's.`,
338
+ run: async (_argument, options) => {
326
339
  const message = options.get('--message');
327
- return publish(publishingIo(), ROOT, options.has('--starter'), typeof message === 'string' ? message : undefined);
340
+ const isStarter = options.has('--starter');
341
+ let starter;
342
+ try {
343
+ starter = isStarter ? await starterToPublish() : undefined;
344
+ }
345
+ catch (error) {
346
+ return mediaRefusal(`The starter can't go out: ${error instanceof Error ? error.message : String(error)}`);
347
+ }
348
+ return publish(publishingIo(), ROOT, isStarter, typeof message === 'string' ? message : undefined, starter);
328
349
  },
329
350
  flags: ['--starter', '--message']
330
351
  },
@@ -353,7 +374,7 @@ existing ${SHIM_PATH} that was not installed by Pattern.`,
353
374
  },
354
375
  help: {
355
376
  summary: 'Show this help (`app <command> --help` for details)',
356
- details: 'Lists commands. Flags: --json (machine-readable stdout), --help, --no-open (start only), --visual (test only), --commands, --name, --as and --signed-out (screenshot only), --alt (media only).',
377
+ details: 'Lists commands. Flags: --json (machine-readable stdout), --help, --no-open (start only), --visual (test only), --commands, --name, --as and --signed-out (screenshot only), --alt and --live (media only).',
357
378
  run: help
358
379
  }
359
380
  };
@@ -693,7 +714,22 @@ async function projectData() {
693
714
  if (typeof seed !== 'object' || seed === null) {
694
715
  throw new Error('seed.ts must export `seed`');
695
716
  }
696
- return { tables: tables, seed };
717
+ const starter = await readStarter(ROOT, tables);
718
+ return {
719
+ tables: tables,
720
+ seed: withStarter(seed, starter),
721
+ photos: starter?.photos ?? []
722
+ };
723
+ }
724
+ async function starterToPublish() {
725
+ const tables = await importExport('schema.ts', 'tables');
726
+ if (typeof tables !== 'object' || tables === null) {
727
+ throw new Error('schema.ts must export `tables`');
728
+ }
729
+ const starter = await readStarter(ROOT, tables);
730
+ return starter === undefined
731
+ ? undefined
732
+ : starterContentOf(tables, starter);
697
733
  }
698
734
  function fingerprint(seed) {
699
735
  return createHash('sha256').update(JSON.stringify(seed)).digest('hex');
@@ -714,18 +750,22 @@ async function seedChangedNotice() {
714
750
  : undefined;
715
751
  return previous === fingerprint(seed)
716
752
  ? undefined
717
- : ' seed seed.ts changed since the local database was filled; app reset fills it again';
753
+ : ' seed seed.ts or starter/ changed since the local database was filled; app reset fills it again';
718
754
  }
719
755
  async function reset() {
720
756
  try {
721
- const { tables, seed } = await projectData();
757
+ const { tables, seed, photos } = await projectData();
722
758
  resetLocalDatabase(ROOT, tables, seed);
759
+ putLocalPhotos(ROOT, photos);
723
760
  recordSeed(seed);
724
761
  const rows = Object.values(seed).reduce((total, list) => total + (list?.length ?? 0), 0);
762
+ const from = existsSync(join(ROOT, 'starter'))
763
+ ? 'seed.ts and starter/'
764
+ : 'seed.ts';
725
765
  return {
726
766
  code: 0,
727
- data: { rows },
728
- text: `✓ Local database emptied and filled with the ${rows} rows in seed.ts\n next reload the page`
767
+ data: { rows, photos: photos.length },
768
+ text: `✓ Local database emptied and filled with the ${rows} rows in ${from}\n next reload the page`
729
769
  };
730
770
  }
731
771
  catch (error) {
@@ -1255,35 +1295,20 @@ async function firstAdmin() {
1255
1295
  const first = Array.isArray(admins) ? admins[0] : undefined;
1256
1296
  return typeof first === 'string' ? first : undefined;
1257
1297
  }
1258
- async function addMedia(args, options) {
1259
- const [action, file] = args;
1260
- if (action !== 'add' || file === undefined) {
1261
- return mediaRefusal(`Say which photo to add\n${MEDIA_USAGE}`, 2);
1262
- }
1263
- const alt = options.get('--alt');
1264
- if (typeof alt !== 'string' || alt.trim() === '') {
1265
- return mediaRefusal(`--alt is required: describe this photo for someone who can't see it\n${MEDIA_USAGE}`, 2);
1266
- }
1267
- const path = resolve(process.cwd(), file);
1268
- if (!existsSync(path))
1269
- return mediaRefusal(`There is no file at ${file}`);
1270
- const bytes = readFileSync(path);
1271
- const extension = extname(path).toLowerCase();
1272
- if (HEIC_EXTENSIONS.has(extension) || isHeic(bytes)) {
1273
- return mediaRefusal(`${file} is an iPhone HEIC photo, which browsers can't show. Convert it to JPEG, then add the JPEG:\n sips -s format jpeg ${file} --out ${file.replace(/\.[^.]+$/, '')}-converted.jpg`);
1274
- }
1275
- const type = PHOTO_TYPES[extension];
1276
- if (type === undefined) {
1277
- return mediaRefusal(`${file} is not a photo the web can show: use JPEG, PNG, WebP, GIF or AVIF`);
1278
- }
1279
- if (bytes.byteLength > MEDIA_LIMIT_BYTES) {
1280
- return mediaRefusal(`${file} is over 10 MB: export it smaller first`);
1281
- }
1298
+ function photoForm(bytes, type, path, alt, id) {
1299
+ const form = new FormData();
1300
+ form.set('file', new Blob([bytes], { type }), basename(path));
1301
+ form.set('alt', alt);
1302
+ if (id !== undefined)
1303
+ form.set('id', id);
1304
+ return form;
1305
+ }
1306
+ async function addLocalMedia(form, file) {
1282
1307
  const admin = await firstAdmin();
1283
1308
  if (admin === undefined) {
1284
- return mediaRefusal("Only an admin can add photos: put the owner's email in admins in schema.ts");
1309
+ return "only an admin can add photos: put the owner's email in admins in schema.ts";
1285
1310
  }
1286
- return withDevServer(async (url) => {
1311
+ const outcome = await withDevServer(async (url) => {
1287
1312
  const origin = new URL(url).origin;
1288
1313
  const signedIn = await fetch(new URL('/api/auth/dev-sign-in', url), {
1289
1314
  method: 'POST',
@@ -1291,15 +1316,12 @@ async function addMedia(args, options) {
1291
1316
  body: JSON.stringify({ email: admin })
1292
1317
  });
1293
1318
  if (!signedIn.ok) {
1294
- return mediaRefusal(`Could not sign in as ${admin} (HTTP ${signedIn.status})`);
1319
+ return mediaRefusal(`could not sign in as ${admin} (HTTP ${signedIn.status})`);
1295
1320
  }
1296
1321
  const cookie = signedIn.headers
1297
1322
  .getSetCookie()
1298
1323
  .map((line) => line.split(';')[0])
1299
1324
  .join('; ');
1300
- const form = new FormData();
1301
- form.set('file', new Blob([bytes], { type }), basename(path));
1302
- form.set('alt', alt.trim());
1303
1325
  const response = await fetch(new URL('/api/media', url), {
1304
1326
  method: 'POST',
1305
1327
  headers: { origin, cookie },
@@ -1307,25 +1329,88 @@ async function addMedia(args, options) {
1307
1329
  });
1308
1330
  const reply = await response.json().catch(() => undefined);
1309
1331
  if (response.status === 404) {
1310
- return mediaRefusal("This app has no media table: add media from '@trypattern/sdk/schema' to tables in schema.ts, run app migration add_media, and bind an R2 bucket as MEDIA in wrangler.jsonc");
1332
+ return mediaRefusal("this app has no media table: add media from '@trypattern/sdk/schema' to tables in schema.ts, run app migration add_media, and bind an R2 bucket as MEDIA in wrangler.jsonc");
1311
1333
  }
1312
1334
  if (!response.ok) {
1313
- return mediaRefusal(`Could not add ${file}: ${replyError(reply) ?? `HTTP ${response.status}`}`);
1335
+ return mediaRefusal(`could not add ${file}: ${replyError(reply) ?? `HTTP ${response.status}`}`);
1314
1336
  }
1315
- const row = Object(reply);
1337
+ return { code: 0, data: { media: Object(reply) }, text: '' };
1338
+ });
1339
+ const row = outcome.data['media'];
1340
+ return outcome.code === 0 && typeof row === 'object' && row !== null
1341
+ ? row
1342
+ : String(outcome.data['error'] ?? outcome.text);
1343
+ }
1344
+ function mediaLines(row) {
1345
+ return [
1346
+ ` id ${String(row['id'])}`,
1347
+ ` key ${String(row['key'])}`,
1348
+ ` size ${String(row['width'])}×${String(row['height'])}`,
1349
+ ` alt ${String(row['alt'])}`
1350
+ ];
1351
+ }
1352
+ async function addMedia(args, options) {
1353
+ const [action, file] = args;
1354
+ if (action !== 'add' || file === undefined) {
1355
+ return mediaRefusal(`Say which photo to add\n${MEDIA_USAGE}`, 2);
1356
+ }
1357
+ const alt = options.get('--alt');
1358
+ if (typeof alt !== 'string' || alt.trim() === '') {
1359
+ return mediaRefusal(`--alt is required: describe this photo for someone who can't see it\n${MEDIA_USAGE}`, 2);
1360
+ }
1361
+ const path = resolve(process.cwd(), file);
1362
+ if (!existsSync(path))
1363
+ return mediaRefusal(`There is no file at ${file}`);
1364
+ const bytes = readFileSync(path);
1365
+ const extension = extname(path).toLowerCase();
1366
+ if (HEIC_EXTENSIONS.has(extension) || isHeic(bytes)) {
1367
+ return mediaRefusal(`${file} is an iPhone HEIC photo, which browsers can't show. Convert it to JPEG, then add the JPEG:\n sips -s format jpeg ${file} --out ${file.replace(/\.[^.]+$/, '')}-converted.jpg`);
1368
+ }
1369
+ const type = PHOTO_TYPES[extension];
1370
+ if (type === undefined) {
1371
+ return mediaRefusal(`${file} is not a photo the web can show: use JPEG, PNG, WebP, GIF or AVIF`);
1372
+ }
1373
+ if (bytes.byteLength > MEDIA_LIMIT_BYTES) {
1374
+ return mediaRefusal(`${file} is over 10 MB: export it smaller first`);
1375
+ }
1376
+ const said = alt.trim();
1377
+ const site = `${readProjectFile(ROOT).name}.onpattern.app`;
1378
+ if (!options.has('--live')) {
1379
+ const row = await addLocalMedia(photoForm(bytes, type, path, said, undefined), file);
1380
+ if (typeof row === 'string')
1381
+ return mediaRefusal(`Could not add ${file}: ${row}`);
1316
1382
  return {
1317
1383
  code: 0,
1318
- data: { media: row },
1384
+ data: { media: row, live: false },
1319
1385
  text: [
1320
- `✓ Added ${file} to media`,
1321
- ` id ${String(row['id'])}`,
1322
- ` key ${String(row['key'])}`,
1323
- ` size ${String(row['width'])}×${String(row['height'])}`,
1324
- ` alt ${String(row['alt'])}`,
1386
+ `✓ Added ${file} to media on this computer`,
1387
+ ...mediaLines(row),
1388
+ ` live not on ${site}: add --live to put it there too`,
1325
1389
  ' show <Image image={row} sizes="…" /> with that media row'
1326
1390
  ].join('\n')
1327
1391
  };
1328
- });
1392
+ }
1393
+ const live = await addLiveMedia(publishingIo(), ROOT, photoForm(bytes, type, path, said, undefined), `app media add ${file} --alt ${JSON.stringify(said)} --live`);
1394
+ if (live.code !== 0)
1395
+ return live;
1396
+ const row = live.data['media'];
1397
+ const local = await addLocalMedia(photoForm(bytes, type, path, said, String(row['id'])), file);
1398
+ return {
1399
+ code: 0,
1400
+ data: {
1401
+ media: row,
1402
+ live: true,
1403
+ local: typeof local === 'string' ? { error: local } : local
1404
+ },
1405
+ text: [
1406
+ `✓ Added ${file} to media on ${site}${typeof local === 'string' ? '' : ' and on this computer'}`,
1407
+ ...mediaLines(row),
1408
+ ...(typeof local === 'string'
1409
+ ? [` local not added on this computer: ${local}`]
1410
+ : []),
1411
+ ` next the owner attaches it to an item on the live site's Manage page; Claude can't change live rows`
1412
+ ].join('\n')
1413
+ };
1329
1414
  }
1330
1415
  async function build() {
1331
1416
  let vite;
@@ -1441,7 +1526,7 @@ async function setup() {
1441
1526
  function help() {
1442
1527
  const width = Math.max(...Object.keys(COMMANDS).map((n) => n.length));
1443
1528
  const lines = Object.entries(COMMANDS).map(([name, cmd]) => `app ${name.padEnd(width)} ${cmd.summary}`);
1444
- lines.push('Flags: --json (machine-readable stdout), <command> --help (details and its own flags: start --no-open, test --visual, screenshot --commands --name --as --signed-out, pull --code, publish --message, media --alt)');
1529
+ lines.push('Flags: --json (machine-readable stdout), <command> --help (details and its own flags: start --no-open, test --visual, screenshot --commands --name --as --signed-out, pull --code, publish --message, media --alt --live)');
1445
1530
  return {
1446
1531
  code: 0,
1447
1532
  data: {
@@ -1,3 +1,4 @@
1
+ import type { StarterContent } from '../publishWire.ts';
1
2
  type Outcome = {
2
3
  code: number;
3
4
  data: Record<string, unknown>;
@@ -15,7 +16,8 @@ export type PublishingIo = {
15
16
  };
16
17
  export declare function login(io: PublishingIo): Promise<Outcome>;
17
18
  export declare function pull(io: PublishingIo, name: string | undefined, cwd: string, setupCode: string | undefined): Promise<Outcome>;
18
- export declare function publish(io: PublishingIo, root: string, isStarter: boolean, message: string | undefined): Promise<Outcome>;
19
+ export declare function publish(io: PublishingIo, root: string, isStarter: boolean, message: string | undefined, starter: StarterContent | undefined): Promise<Outcome>;
20
+ export declare function addLiveMedia(io: PublishingIo, root: string, form: FormData, again: string): Promise<Outcome>;
19
21
  export declare function versions(io: PublishingIo, root: string): Promise<Outcome>;
20
22
  export declare function restore(io: PublishingIo, root: string, number: string | undefined): Promise<Outcome>;
21
23
  export {};
@@ -3,10 +3,10 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, st
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, dirname, extname, join, matchesGlob, relative } from 'node:path';
5
5
  import { z } from 'zod';
6
- import { DASHBOARD_URL, UPLOAD, appSummary, isPrivatePath, loginPoll, loginStarted, nameProblem, publishMessage, publishSummary, setupCodeExchanged, sourcePathProblem } from "../publishWire.js";
6
+ import { DASHBOARD_URL, UPLOAD, appSummary, isPrivatePath, loginPoll, loginStarted, mediaRow, nameProblem, publishMessage, publishSummary, setupCodeExchanged, sourcePathProblem } from "../publishWire.js";
7
7
  import { appInfo } from "../appTitle.js";
8
8
  import { SESSION_COOKIE } from "../auth/user.js";
9
- import { PROJECT_FILE, readProjectFile } from "../vite/projectFile.js";
9
+ import { PROJECT_FILE, blueprintOf, readProjectFile } from "../vite/projectFile.js";
10
10
  const CONFIG_DIR = join(homedir(), '.config', 'pattern');
11
11
  const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
12
12
  const PENDING_FILE = join(CONFIG_DIR, 'pending-login.json');
@@ -51,6 +51,9 @@ const STEP_WORDS = {
51
51
  function dashboard() {
52
52
  return (process.env['PATTERN_DASHBOARD_URL'] ?? DASHBOARD_URL).replace(/\/$/, '');
53
53
  }
54
+ function sayHost(io) {
55
+ io.say(` via ${new URL(dashboard()).host}`);
56
+ }
54
57
  const credentials = z.record(z.string(), z.object({ cookie: z.string() }));
55
58
  const pendingLogin = z.object({
56
59
  dashboard: z.string(),
@@ -280,6 +283,7 @@ export async function pull(io, name, cwd, setupCode) {
280
283
  text: '✗ Which site? app pull acmeflower'
281
284
  };
282
285
  }
286
+ sayHost(io);
283
287
  const target = folderFor(name, cwd);
284
288
  if (target !== cwd && existsSync(target) && !isEmptyFolder(target)) {
285
289
  return {
@@ -321,7 +325,8 @@ export async function pull(io, name, cwd, setupCode) {
321
325
  }
322
326
  if (existsSync(join(target, LAUNCHER)))
323
327
  chmodSync(join(target, LAUNCHER), 0o755);
324
- writeFileSync(join(target, PROJECT_FILE), `${JSON.stringify({ name, title: result.title }, null, 2)}\n`);
328
+ const blueprint = blueprintOf(target);
329
+ writeFileSync(join(target, PROJECT_FILE), `${JSON.stringify({ name, title: result.title, ...(blueprint === undefined ? {} : { blueprint }) }, null, 2)}\n`);
325
330
  io.say(`✓ Downloaded ${files} files of version ${result.number}`);
326
331
  if (!(await ensurePnpm(io, target))) {
327
332
  return {
@@ -403,7 +408,7 @@ function workerDirectory(dist) {
403
408
  function starterPackageJson(path, sdkVersion) {
404
409
  return readFileSync(path, 'utf8').replace(/"@trypattern\/sdk": "workspace:[^"]*"/, `"@trypattern/sdk": "${sdkVersion}"`);
405
410
  }
406
- function uploadForm(root, io, isStarter) {
411
+ function uploadForm(root, io, isStarter, starter) {
407
412
  const dist = join(root, 'dist');
408
413
  const worker = workerDirectory(dist);
409
414
  if (worker === undefined)
@@ -431,6 +436,13 @@ function uploadForm(root, io, isStarter) {
431
436
  form.append(`${UPLOAD.migrations}${file}`, fileBlob(join(migrations, file)), file);
432
437
  }
433
438
  const sources = sourceFiles(root);
439
+ if (starter !== undefined) {
440
+ const left = starter.photos.find((photo) => !sources.includes(photo.path));
441
+ if (left !== undefined) {
442
+ throw new Error(`${left.path} is ignored by .gitignore, so it can't go with the starter`);
443
+ }
444
+ form.append(UPLOAD.starter, JSON.stringify(starter));
445
+ }
434
446
  if (isStarter && !sources.includes(STARTER_WORKSPACE)) {
435
447
  form.append(`${UPLOAD.source}${STARTER_WORKSPACE}`, new Blob([STARTER_BUILD_SETTINGS], { type: 'text/yaml' }), STARTER_WORKSPACE);
436
448
  }
@@ -478,18 +490,25 @@ async function followPublish(io, name, started) {
478
490
  text: `✗ Version ${publish.number} is still publishing after ${PUBLISH_WAIT_MS / 60_000} minutes\n next app versions shows how it ends`
479
491
  };
480
492
  }
481
- export async function publish(io, root, isStarter, message) {
493
+ export async function publish(io, root, isStarter, message, starter) {
482
494
  try {
483
- const { name } = readProjectFile(root);
495
+ sayHost(io);
496
+ const { name, blueprint } = readProjectFile(root);
497
+ if (isStarter && blueprint === undefined) {
498
+ throw new Error(`${PROJECT_FILE} needs "blueprint" ("website" or "web-app") to publish the starter for it`);
499
+ }
484
500
  const built = await io.build();
485
501
  if (built.code !== 0)
486
502
  return built;
487
503
  io.say('✓ Built');
488
- const { form, sources } = uploadForm(root, io, isStarter);
504
+ const { form, sources } = uploadForm(root, io, isStarter, starter);
505
+ if (isStarter && blueprint !== undefined) {
506
+ form.append(UPLOAD.blueprint, blueprint);
507
+ }
489
508
  const said = publishMessage(message ?? '');
490
509
  if (said !== null)
491
510
  form.append(UPLOAD.message, said);
492
- io.say(`✓ Packed the build and ${sources} source files`);
511
+ io.say(`✓ Packed the build and ${sources} source files${starter === undefined ? '' : `, with ${starter.photos.length} starter photos`}`);
493
512
  const again = isStarter ? 'app publish --starter' : 'app publish';
494
513
  const result = await signedIn(io, again, async () => {
495
514
  if (isStarter) {
@@ -509,8 +528,8 @@ export async function publish(io, root, isStarter, message) {
509
528
  if (result === undefined) {
510
529
  return {
511
530
  code: 0,
512
- data: { starter: io.sdkVersion },
513
- text: `✓ Published the starter for SDK ${io.sdkVersion}`
531
+ data: { starter: io.sdkVersion, blueprint },
532
+ text: `✓ Published the ${blueprint} starter for SDK ${io.sdkVersion}`
514
533
  };
515
534
  }
516
535
  return await followPublish(io, name, result);
@@ -519,6 +538,22 @@ export async function publish(io, root, isStarter, message) {
519
538
  return failure(error);
520
539
  }
521
540
  }
541
+ export async function addLiveMedia(io, root, form, again) {
542
+ try {
543
+ sayHost(io);
544
+ const { name } = readProjectFile(root);
545
+ const result = await signedIn(io, again, () => callJson(`/api/apps/${name}/media`, mediaRow, {
546
+ method: 'POST',
547
+ body: form
548
+ }));
549
+ if (isOutcome(result))
550
+ return result;
551
+ return { code: 0, data: { name, media: result }, text: '' };
552
+ }
553
+ catch (error) {
554
+ return failure(error);
555
+ }
556
+ }
522
557
  export async function versions(io, root) {
523
558
  try {
524
559
  const { name } = readProjectFile(root);
@@ -0,0 +1,17 @@
1
+ import type { StarterContent } from '../publishWire.ts';
2
+ import type { PhotoFacts } from '../server/media.ts';
3
+ import type { Seed, Tables } from '../table.ts';
4
+ export declare const STARTER_ROWS = "starter/rows.ts";
5
+ export declare const STARTER_PHOTOS = "starter/photos";
6
+ export type StarterPhotoFile = PhotoFacts & {
7
+ id: string;
8
+ alt: string;
9
+ path: string;
10
+ };
11
+ export type LoadedStarter = {
12
+ photos: readonly StarterPhotoFile[];
13
+ rows: Seed<Tables>;
14
+ };
15
+ export declare function readStarter(root: string, tables: Tables): Promise<LoadedStarter | undefined>;
16
+ export declare function withStarter(seed: Seed<Tables>, starter: LoadedStarter | undefined): Seed<Tables>;
17
+ export declare function starterContentOf(tables: Tables, starter: LoadedStarter): StarterContent;