@trypattern/sdk 0.1.11 → 0.1.13

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 (87) 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 -17
  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 +143 -59
  17. package/dist/cli/publishing.d.ts +3 -1
  18. package/dist/cli/publishing.js +33 -5
  19. package/dist/cli/starter.d.ts +17 -0
  20. package/dist/cli/starter.js +140 -0
  21. package/dist/components/Button.d.ts +2 -24
  22. package/dist/components/Button.js +4 -251
  23. package/dist/components/ButtonBody.d.ts +25 -0
  24. package/dist/components/ButtonBody.js +266 -0
  25. package/dist/components/Calendar.d.ts +20 -0
  26. package/dist/components/Calendar.js +195 -0
  27. package/dist/components/Card.d.ts +1 -1
  28. package/dist/components/Card.js +7 -29
  29. package/dist/components/Choice.js +27 -13
  30. package/dist/components/ChoiceGroup.d.ts +3 -1
  31. package/dist/components/ChoiceGroup.js +3 -4
  32. package/dist/components/ContinueWithGoogle.d.ts +11 -0
  33. package/dist/components/ContinueWithGoogle.js +5 -0
  34. package/dist/components/Dialog.d.ts +1 -1
  35. package/dist/components/Dialog.js +3 -1
  36. package/dist/components/Icon.js +1 -1
  37. package/dist/components/ListRow.js +2 -1
  38. package/dist/components/RadioGroup.d.ts +2 -1
  39. package/dist/components/RadioGroup.js +10 -2
  40. package/dist/components/Text.d.ts +1 -1
  41. package/dist/components/Text.js +43 -2
  42. package/dist/components/calendarDates.d.ts +14 -0
  43. package/dist/components/calendarDates.js +84 -0
  44. package/dist/components/controlLook.js +11 -11
  45. package/dist/components/icons.d.ts +9 -0
  46. package/dist/components/icons.js +17 -1
  47. package/dist/components/interaction.d.ts +1 -1
  48. package/dist/flows/browser.d.ts +7 -1
  49. package/dist/flows/browser.js +25 -7
  50. package/dist/flows/flowBrowser.d.ts +2 -2
  51. package/dist/flows/flowBrowser.js +2 -2
  52. package/dist/flows/flows.js +8 -2
  53. package/dist/index.d.ts +4 -1
  54. package/dist/index.js +1 -0
  55. package/dist/media.d.ts +11 -1
  56. package/dist/patternAccess.d.ts +11 -2
  57. package/dist/patternAccess.js +8 -4
  58. package/dist/publishWire.d.ts +24 -2
  59. package/dist/publishWire.js +22 -2
  60. package/dist/react.d.ts +3 -0
  61. package/dist/react.js +2 -0
  62. package/dist/rules/checkAllFiles.js +16 -2
  63. package/dist/rules/lintPlugin.js +1 -0
  64. package/dist/schema.d.ts +3 -1
  65. package/dist/schema.js +1 -0
  66. package/dist/server/bookings.d.ts +12 -0
  67. package/dist/server/bookings.js +142 -0
  68. package/dist/server/createServer.d.ts +3 -2
  69. package/dist/server/createServer.js +13 -4
  70. package/dist/server/env.d.ts +8 -0
  71. package/dist/server/index.d.ts +3 -1
  72. package/dist/server/index.js +1 -0
  73. package/dist/server/media.d.ts +16 -3
  74. package/dist/server/media.js +72 -23
  75. package/dist/server/patternViewer.js +16 -10
  76. package/dist/server/tables.d.ts +2 -0
  77. package/dist/server/tables.js +17 -1
  78. package/dist/start.js +12 -0
  79. package/dist/table.js +9 -3
  80. package/dist/theme/scales.d.ts +1 -1
  81. package/dist/theme/scales.js +1 -0
  82. package/dist/theme/tokens.stylex.d.ts +18 -0
  83. package/dist/theme/tokens.stylex.js +19 -1
  84. package/dist/vite/localWorker.d.ts +2 -0
  85. package/dist/vite/localWorker.js +48 -3
  86. package/dist/vite/vite.js +5 -1
  87. 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,21 @@ 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 every new site begins from.
334
+ With a starter/ folder, its rows (starter/rows.ts) and photos (starter/photos) go with it, and a new
335
+ site's first publish puts them in its own database and photo bucket: the photos through the site's
336
+ own POST /api/media, then the rows. Later publishes never touch them; they are the owner's.`,
337
+ run: async (_argument, options) => {
326
338
  const message = options.get('--message');
327
- return publish(publishingIo(), ROOT, options.has('--starter'), typeof message === 'string' ? message : undefined);
339
+ const isStarter = options.has('--starter');
340
+ let starter;
341
+ try {
342
+ starter = isStarter ? await starterToPublish() : undefined;
343
+ }
344
+ catch (error) {
345
+ return mediaRefusal(`The starter can't go out: ${error instanceof Error ? error.message : String(error)}`);
346
+ }
347
+ return publish(publishingIo(), ROOT, isStarter, typeof message === 'string' ? message : undefined, starter);
328
348
  },
329
349
  flags: ['--starter', '--message']
330
350
  },
@@ -353,7 +373,7 @@ existing ${SHIM_PATH} that was not installed by Pattern.`,
353
373
  },
354
374
  help: {
355
375
  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).',
376
+ 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
377
  run: help
358
378
  }
359
379
  };
@@ -693,7 +713,22 @@ async function projectData() {
693
713
  if (typeof seed !== 'object' || seed === null) {
694
714
  throw new Error('seed.ts must export `seed`');
695
715
  }
696
- return { tables: tables, seed };
716
+ const starter = await readStarter(ROOT, tables);
717
+ return {
718
+ tables: tables,
719
+ seed: withStarter(seed, starter),
720
+ photos: starter?.photos ?? []
721
+ };
722
+ }
723
+ async function starterToPublish() {
724
+ const tables = await importExport('schema.ts', 'tables');
725
+ if (typeof tables !== 'object' || tables === null) {
726
+ throw new Error('schema.ts must export `tables`');
727
+ }
728
+ const starter = await readStarter(ROOT, tables);
729
+ return starter === undefined
730
+ ? undefined
731
+ : starterContentOf(tables, starter);
697
732
  }
698
733
  function fingerprint(seed) {
699
734
  return createHash('sha256').update(JSON.stringify(seed)).digest('hex');
@@ -714,18 +749,22 @@ async function seedChangedNotice() {
714
749
  : undefined;
715
750
  return previous === fingerprint(seed)
716
751
  ? undefined
717
- : ' seed seed.ts changed since the local database was filled; app reset fills it again';
752
+ : ' seed seed.ts or starter/ changed since the local database was filled; app reset fills it again';
718
753
  }
719
754
  async function reset() {
720
755
  try {
721
- const { tables, seed } = await projectData();
756
+ const { tables, seed, photos } = await projectData();
722
757
  resetLocalDatabase(ROOT, tables, seed);
758
+ putLocalPhotos(ROOT, photos);
723
759
  recordSeed(seed);
724
760
  const rows = Object.values(seed).reduce((total, list) => total + (list?.length ?? 0), 0);
761
+ const from = existsSync(join(ROOT, 'starter'))
762
+ ? 'seed.ts and starter/'
763
+ : 'seed.ts';
725
764
  return {
726
765
  code: 0,
727
- data: { rows },
728
- text: `✓ Local database emptied and filled with the ${rows} rows in seed.ts\n next reload the page`
766
+ data: { rows, photos: photos.length },
767
+ text: `✓ Local database emptied and filled with the ${rows} rows in ${from}\n next reload the page`
729
768
  };
730
769
  }
731
770
  catch (error) {
@@ -1255,35 +1294,20 @@ async function firstAdmin() {
1255
1294
  const first = Array.isArray(admins) ? admins[0] : undefined;
1256
1295
  return typeof first === 'string' ? first : undefined;
1257
1296
  }
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
- }
1297
+ function photoForm(bytes, type, path, alt, id) {
1298
+ const form = new FormData();
1299
+ form.set('file', new Blob([bytes], { type }), basename(path));
1300
+ form.set('alt', alt);
1301
+ if (id !== undefined)
1302
+ form.set('id', id);
1303
+ return form;
1304
+ }
1305
+ async function addLocalMedia(form, file) {
1282
1306
  const admin = await firstAdmin();
1283
1307
  if (admin === undefined) {
1284
- return mediaRefusal("Only an admin can add photos: put the owner's email in admins in schema.ts");
1308
+ return "only an admin can add photos: put the owner's email in admins in schema.ts";
1285
1309
  }
1286
- return withDevServer(async (url) => {
1310
+ const outcome = await withDevServer(async (url) => {
1287
1311
  const origin = new URL(url).origin;
1288
1312
  const signedIn = await fetch(new URL('/api/auth/dev-sign-in', url), {
1289
1313
  method: 'POST',
@@ -1291,15 +1315,12 @@ async function addMedia(args, options) {
1291
1315
  body: JSON.stringify({ email: admin })
1292
1316
  });
1293
1317
  if (!signedIn.ok) {
1294
- return mediaRefusal(`Could not sign in as ${admin} (HTTP ${signedIn.status})`);
1318
+ return mediaRefusal(`could not sign in as ${admin} (HTTP ${signedIn.status})`);
1295
1319
  }
1296
1320
  const cookie = signedIn.headers
1297
1321
  .getSetCookie()
1298
1322
  .map((line) => line.split(';')[0])
1299
1323
  .join('; ');
1300
- const form = new FormData();
1301
- form.set('file', new Blob([bytes], { type }), basename(path));
1302
- form.set('alt', alt.trim());
1303
1324
  const response = await fetch(new URL('/api/media', url), {
1304
1325
  method: 'POST',
1305
1326
  headers: { origin, cookie },
@@ -1307,25 +1328,88 @@ async function addMedia(args, options) {
1307
1328
  });
1308
1329
  const reply = await response.json().catch(() => undefined);
1309
1330
  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");
1331
+ 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
1332
  }
1312
1333
  if (!response.ok) {
1313
- return mediaRefusal(`Could not add ${file}: ${replyError(reply) ?? `HTTP ${response.status}`}`);
1334
+ return mediaRefusal(`could not add ${file}: ${replyError(reply) ?? `HTTP ${response.status}`}`);
1314
1335
  }
1315
- const row = Object(reply);
1336
+ return { code: 0, data: { media: Object(reply) }, text: '' };
1337
+ });
1338
+ const row = outcome.data['media'];
1339
+ return outcome.code === 0 && typeof row === 'object' && row !== null
1340
+ ? row
1341
+ : String(outcome.data['error'] ?? outcome.text);
1342
+ }
1343
+ function mediaLines(row) {
1344
+ return [
1345
+ ` id ${String(row['id'])}`,
1346
+ ` key ${String(row['key'])}`,
1347
+ ` size ${String(row['width'])}×${String(row['height'])}`,
1348
+ ` alt ${String(row['alt'])}`
1349
+ ];
1350
+ }
1351
+ async function addMedia(args, options) {
1352
+ const [action, file] = args;
1353
+ if (action !== 'add' || file === undefined) {
1354
+ return mediaRefusal(`Say which photo to add\n${MEDIA_USAGE}`, 2);
1355
+ }
1356
+ const alt = options.get('--alt');
1357
+ if (typeof alt !== 'string' || alt.trim() === '') {
1358
+ return mediaRefusal(`--alt is required: describe this photo for someone who can't see it\n${MEDIA_USAGE}`, 2);
1359
+ }
1360
+ const path = resolve(process.cwd(), file);
1361
+ if (!existsSync(path))
1362
+ return mediaRefusal(`There is no file at ${file}`);
1363
+ const bytes = readFileSync(path);
1364
+ const extension = extname(path).toLowerCase();
1365
+ if (HEIC_EXTENSIONS.has(extension) || isHeic(bytes)) {
1366
+ 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`);
1367
+ }
1368
+ const type = PHOTO_TYPES[extension];
1369
+ if (type === undefined) {
1370
+ return mediaRefusal(`${file} is not a photo the web can show: use JPEG, PNG, WebP, GIF or AVIF`);
1371
+ }
1372
+ if (bytes.byteLength > MEDIA_LIMIT_BYTES) {
1373
+ return mediaRefusal(`${file} is over 10 MB: export it smaller first`);
1374
+ }
1375
+ const said = alt.trim();
1376
+ const site = `${readProjectFile(ROOT).name}.onpattern.app`;
1377
+ if (!options.has('--live')) {
1378
+ const row = await addLocalMedia(photoForm(bytes, type, path, said, undefined), file);
1379
+ if (typeof row === 'string')
1380
+ return mediaRefusal(`Could not add ${file}: ${row}`);
1316
1381
  return {
1317
1382
  code: 0,
1318
- data: { media: row },
1383
+ data: { media: row, live: false },
1319
1384
  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'])}`,
1385
+ `✓ Added ${file} to media on this computer`,
1386
+ ...mediaLines(row),
1387
+ ` live not on ${site}: add --live to put it there too`,
1325
1388
  ' show <Image image={row} sizes="…" /> with that media row'
1326
1389
  ].join('\n')
1327
1390
  };
1328
- });
1391
+ }
1392
+ const live = await addLiveMedia(publishingIo(), ROOT, photoForm(bytes, type, path, said, undefined), `app media add ${file} --alt ${JSON.stringify(said)} --live`);
1393
+ if (live.code !== 0)
1394
+ return live;
1395
+ const row = live.data['media'];
1396
+ const local = await addLocalMedia(photoForm(bytes, type, path, said, String(row['id'])), file);
1397
+ return {
1398
+ code: 0,
1399
+ data: {
1400
+ media: row,
1401
+ live: true,
1402
+ local: typeof local === 'string' ? { error: local } : local
1403
+ },
1404
+ text: [
1405
+ `✓ Added ${file} to media on ${site}${typeof local === 'string' ? '' : ' and on this computer'}`,
1406
+ ...mediaLines(row),
1407
+ ...(typeof local === 'string'
1408
+ ? [` local not added on this computer: ${local}`]
1409
+ : []),
1410
+ ` next the owner attaches it to an item on the live site's Manage page; Claude can't change live rows`
1411
+ ].join('\n')
1412
+ };
1329
1413
  }
1330
1414
  async function build() {
1331
1415
  let vite;
@@ -1441,7 +1525,7 @@ async function setup() {
1441
1525
  function help() {
1442
1526
  const width = Math.max(...Object.keys(COMMANDS).map((n) => n.length));
1443
1527
  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)');
1528
+ 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
1529
  return {
1446
1530
  code: 0,
1447
1531
  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,7 +3,7 @@ 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
9
  import { PROJECT_FILE, readProjectFile } from "../vite/projectFile.js";
@@ -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 {
@@ -403,7 +407,7 @@ function workerDirectory(dist) {
403
407
  function starterPackageJson(path, sdkVersion) {
404
408
  return readFileSync(path, 'utf8').replace(/"@trypattern\/sdk": "workspace:[^"]*"/, `"@trypattern/sdk": "${sdkVersion}"`);
405
409
  }
406
- function uploadForm(root, io, isStarter) {
410
+ function uploadForm(root, io, isStarter, starter) {
407
411
  const dist = join(root, 'dist');
408
412
  const worker = workerDirectory(dist);
409
413
  if (worker === undefined)
@@ -431,6 +435,13 @@ function uploadForm(root, io, isStarter) {
431
435
  form.append(`${UPLOAD.migrations}${file}`, fileBlob(join(migrations, file)), file);
432
436
  }
433
437
  const sources = sourceFiles(root);
438
+ if (starter !== undefined) {
439
+ const left = starter.photos.find((photo) => !sources.includes(photo.path));
440
+ if (left !== undefined) {
441
+ throw new Error(`${left.path} is ignored by .gitignore, so it can't go with the starter`);
442
+ }
443
+ form.append(UPLOAD.starter, JSON.stringify(starter));
444
+ }
434
445
  if (isStarter && !sources.includes(STARTER_WORKSPACE)) {
435
446
  form.append(`${UPLOAD.source}${STARTER_WORKSPACE}`, new Blob([STARTER_BUILD_SETTINGS], { type: 'text/yaml' }), STARTER_WORKSPACE);
436
447
  }
@@ -478,18 +489,19 @@ async function followPublish(io, name, started) {
478
489
  text: `✗ Version ${publish.number} is still publishing after ${PUBLISH_WAIT_MS / 60_000} minutes\n next app versions shows how it ends`
479
490
  };
480
491
  }
481
- export async function publish(io, root, isStarter, message) {
492
+ export async function publish(io, root, isStarter, message, starter) {
482
493
  try {
494
+ sayHost(io);
483
495
  const { name } = readProjectFile(root);
484
496
  const built = await io.build();
485
497
  if (built.code !== 0)
486
498
  return built;
487
499
  io.say('✓ Built');
488
- const { form, sources } = uploadForm(root, io, isStarter);
500
+ const { form, sources } = uploadForm(root, io, isStarter, starter);
489
501
  const said = publishMessage(message ?? '');
490
502
  if (said !== null)
491
503
  form.append(UPLOAD.message, said);
492
- io.say(`✓ Packed the build and ${sources} source files`);
504
+ io.say(`✓ Packed the build and ${sources} source files${starter === undefined ? '' : `, with ${starter.photos.length} starter photos`}`);
493
505
  const again = isStarter ? 'app publish --starter' : 'app publish';
494
506
  const result = await signedIn(io, again, async () => {
495
507
  if (isStarter) {
@@ -519,6 +531,22 @@ export async function publish(io, root, isStarter, message) {
519
531
  return failure(error);
520
532
  }
521
533
  }
534
+ export async function addLiveMedia(io, root, form, again) {
535
+ try {
536
+ sayHost(io);
537
+ const { name } = readProjectFile(root);
538
+ const result = await signedIn(io, again, () => callJson(`/api/apps/${name}/media`, mediaRow, {
539
+ method: 'POST',
540
+ body: form
541
+ }));
542
+ if (isOutcome(result))
543
+ return result;
544
+ return { code: 0, data: { name, media: result }, text: '' };
545
+ }
546
+ catch (error) {
547
+ return failure(error);
548
+ }
549
+ }
522
550
  export async function versions(io, root) {
523
551
  try {
524
552
  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;