@trypattern/sdk 0.1.12 → 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.
- package/README.md +73 -12
- package/dist/access.d.ts +0 -4
- package/dist/access.js +0 -5
- package/dist/api.d.ts +3 -1
- package/dist/api.js +5 -23
- package/dist/app.d.ts +20 -5
- package/dist/app.js +32 -2
- package/dist/blocks.d.ts +18 -0
- package/dist/blocks.js +36 -0
- package/dist/bookings.d.ts +400 -0
- package/dist/bookings.js +300 -0
- package/dist/bookingsMemory.d.ts +9 -0
- package/dist/bookingsMemory.js +79 -0
- package/dist/bookingsStore.d.ts +698 -0
- package/dist/bookingsStore.js +129 -0
- package/dist/cli/app.js +143 -59
- package/dist/cli/publishing.d.ts +3 -1
- package/dist/cli/publishing.js +33 -5
- package/dist/cli/starter.d.ts +17 -0
- package/dist/cli/starter.js +140 -0
- package/dist/components/Calendar.d.ts +20 -0
- package/dist/components/Calendar.js +195 -0
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +7 -29
- package/dist/components/Choice.js +27 -13
- package/dist/components/ChoiceGroup.d.ts +3 -1
- package/dist/components/ChoiceGroup.js +3 -4
- package/dist/components/Dialog.d.ts +1 -1
- package/dist/components/Dialog.js +3 -1
- package/dist/components/ListRow.js +2 -1
- package/dist/components/RadioGroup.d.ts +2 -1
- package/dist/components/RadioGroup.js +10 -2
- package/dist/components/Text.d.ts +1 -1
- package/dist/components/Text.js +43 -2
- package/dist/components/calendarDates.d.ts +14 -0
- package/dist/components/calendarDates.js +84 -0
- package/dist/components/controlLook.js +11 -11
- package/dist/components/icons.d.ts +2 -0
- package/dist/components/icons.js +2 -0
- package/dist/components/interaction.d.ts +1 -1
- package/dist/flows/browser.d.ts +7 -1
- package/dist/flows/browser.js +25 -7
- package/dist/flows/flowBrowser.d.ts +2 -2
- package/dist/flows/flowBrowser.js +2 -2
- package/dist/flows/flows.js +8 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/media.d.ts +11 -1
- package/dist/patternAccess.d.ts +11 -2
- package/dist/patternAccess.js +8 -4
- package/dist/publishWire.d.ts +24 -2
- package/dist/publishWire.js +22 -2
- package/dist/react.d.ts +2 -0
- package/dist/react.js +1 -0
- package/dist/rules/checkAllFiles.js +16 -2
- package/dist/rules/lintPlugin.js +1 -0
- package/dist/schema.d.ts +3 -1
- package/dist/schema.js +1 -0
- package/dist/server/bookings.d.ts +12 -0
- package/dist/server/bookings.js +142 -0
- package/dist/server/createServer.d.ts +3 -2
- package/dist/server/createServer.js +13 -4
- package/dist/server/env.d.ts +8 -0
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/dist/server/media.d.ts +16 -3
- package/dist/server/media.js +72 -23
- package/dist/server/patternViewer.js +16 -10
- package/dist/server/tables.d.ts +2 -0
- package/dist/server/tables.js +17 -1
- package/dist/start.js +12 -0
- package/dist/table.js +9 -3
- package/dist/theme/scales.d.ts +1 -1
- package/dist/theme/scales.js +1 -0
- package/dist/theme/tokens.stylex.d.ts +18 -0
- package/dist/theme/tokens.stylex.js +19 -1
- package/dist/vite/localWorker.d.ts +2 -0
- package/dist/vite/localWorker.js +48 -3
- package/dist/vite/vite.js +5 -1
- 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 {
|
|
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
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
|
1308
|
+
return "only an admin can add photos: put the owner's email in admins in schema.ts";
|
|
1285
1309
|
}
|
|
1286
|
-
|
|
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(`
|
|
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("
|
|
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(`
|
|
1334
|
+
return mediaRefusal(`could not add ${file}: ${replyError(reply) ?? `HTTP ${response.status}`}`);
|
|
1314
1335
|
}
|
|
1315
|
-
|
|
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
|
-
|
|
1322
|
-
`
|
|
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: {
|
package/dist/cli/publishing.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/cli/publishing.js
CHANGED
|
@@ -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;
|