@trypattern/sdk 0.1.21 → 0.1.23

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/dist/cli/app.js CHANGED
@@ -20,7 +20,10 @@ import { putLocalPhotos, resetLocalDatabase } from "../vite/localWorker.js";
20
20
  import { readProjectFile } from "../vite/projectFile.js";
21
21
  import { readStarter, starterContentOf, withStarter } from "./starter.js";
22
22
  import { addLiveMedia, login, publish, pull, restore, versions as listVersions } from "./publishing.js";
23
+ import { CHANGE_SET_ROWS } from "../publishWire.js";
24
+ import { DATA_USAGE, data } from "./data.js";
23
25
  import { toolBin } from "./tools.js";
26
+ import { isNewer, latestPattern, newerPattern, updateNote } from "./updates.js";
24
27
  function dependsOnPattern(directory) {
25
28
  try {
26
29
  const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
@@ -78,13 +81,18 @@ const CONTACT_SHEET = join(SCREENSHOTS_DIR, 'contact-sheet.png');
78
81
  const SCREENSHOT_WORKER = join(SDK_SOURCE, 'cli', `screenshot${SDK_EXTENSION}`);
79
82
  const VISUAL_URL_ENV = 'PATTERN_VISUAL_URL';
80
83
  const GLOBAL_FLAGS = ['--json', '--help', '-h'];
84
+ const NUDGED_COMMANDS = new Set(['start', 'check', 'publish', 'data']);
81
85
  const VALUE_FLAGS = new Set([
82
86
  '--commands',
83
87
  '--name',
84
88
  '--as',
85
89
  '--code',
86
90
  '--message',
87
- '--alt'
91
+ '--alt',
92
+ '--row',
93
+ '--set',
94
+ '--file',
95
+ '--asked'
88
96
  ]);
89
97
  const PHOTO_TYPES = {
90
98
  '.jpg': 'image/jpeg',
@@ -349,6 +357,33 @@ own POST /api/media, then the rows. Later publishes never touch them; they are t
349
357
  },
350
358
  flags: ['--starter', '--message']
351
359
  },
360
+ data: {
361
+ summary: 'Change the live site’s data, with the owner’s approval (app data --help)',
362
+ details: `${DATA_USAGE}
363
+ The live database belongs to the owner: changes to it go to them for approval, never straight in.
364
+ tables and list read the live data (columns, ids and values) through onpattern.app. add, update
365
+ and remove only collect changes on this computer (.dev/data-changes.json); nothing live changes.
366
+ Values use the table's own columns and types, as tables shows them: a price stored in cents is 1400.
367
+ send puts every collected change in front of the owner as one request on onpattern.app (Database)
368
+ and emails them. --asked is required: their request in their own words, quoted, which they see
369
+ beside Approve. Only the owner can approve, in their browser; send then waits for their decision
370
+ and prints what went live. A row the owner changed after you read it is skipped, never
371
+ overwritten: list it again and send a new change. The live site must run a Pattern with app data:
372
+ after updating @trypattern/sdk, app publish once first. status waits on the latest request; clear drops
373
+ collected changes. Up to ${CHANGE_SET_ROWS} changes per request. For this computer's data, edit seed.ts and run app reset.`,
374
+ run: (_argument, options, rest) => data(publishingIo(), ROOT, rest, options),
375
+ argumentCount: 3,
376
+ flags: ['--row', '--set', '--file', '--asked']
377
+ },
378
+ update: {
379
+ summary: 'Bring this project to the newest Pattern',
380
+ details: `Installs the newest @trypattern/sdk from npm into this project (pnpm add, exact version).
381
+ start, check, publish and data say when a newer Pattern is out. The live site keeps the old one until
382
+ the next app publish, so run app check, then publish it (straight away when nothing else is in
383
+ progress). New Pattern features on the live site, such as the owner approving app data changes,
384
+ need that publish.`,
385
+ run: update
386
+ },
352
387
  versions: {
353
388
  summary: 'List the versions that went live, newest first',
354
389
  details: 'Each version with its number, whether it went live, when, and who published it. app restore <number> puts one back.',
@@ -1468,6 +1503,43 @@ function installShim() {
1468
1503
  path: SHIM_PATH
1469
1504
  };
1470
1505
  }
1506
+ async function update() {
1507
+ const current = sdkVersion();
1508
+ const latest = await latestPattern(true);
1509
+ if (latest === undefined) {
1510
+ return {
1511
+ code: 1,
1512
+ data: { current, error: 'npm did not answer' },
1513
+ text: '✗ Couldn’t reach npm to find the newest Pattern. Check the internet connection and try again.'
1514
+ };
1515
+ }
1516
+ if (!isNewer(latest, current)) {
1517
+ return {
1518
+ code: 0,
1519
+ data: { current, latest, updated: false },
1520
+ text: `✓ Pattern is up to date (${current})`
1521
+ };
1522
+ }
1523
+ say(`Updating Pattern ${current} → ${latest}`);
1524
+ const installed = await capture('pnpm', ['add', `@trypattern/sdk@${latest}`]);
1525
+ if (installed.code !== 0) {
1526
+ return {
1527
+ code: 1,
1528
+ data: {
1529
+ current,
1530
+ latest,
1531
+ error: 'pnpm add failed',
1532
+ output: installed.output
1533
+ },
1534
+ text: `✗ pnpm couldn’t install Pattern ${latest}:\n${installed.output.trim().split('\n').slice(-LOG_TAIL_LINES).join('\n')}`
1535
+ };
1536
+ }
1537
+ return {
1538
+ code: 0,
1539
+ data: { current, latest, updated: true },
1540
+ text: `✓ Updated Pattern ${current} → ${latest}\n next run app check, then app publish --message 'Updated Pattern' (straight away if nothing else is in progress) so the live site gets it`
1541
+ };
1542
+ }
1471
1543
  async function setup() {
1472
1544
  const pins = readPins();
1473
1545
  const haveNode = process.versions.node;
@@ -1611,6 +1683,16 @@ async function main(argv) {
1611
1683
  }
1612
1684
  else {
1613
1685
  outcome = await command.run(positional[1], options, positional.slice(1));
1686
+ if (NUDGED_COMMANDS.has(name)) {
1687
+ const latest = await newerPattern(sdkVersion());
1688
+ if (latest !== undefined) {
1689
+ outcome = {
1690
+ ...outcome,
1691
+ data: { ...outcome.data, newerPattern: latest },
1692
+ text: `${outcome.text}\n${updateNote(latest, sdkVersion())}`
1693
+ };
1694
+ }
1695
+ }
1614
1696
  }
1615
1697
  if (jsonMode) {
1616
1698
  const payload = {
@@ -0,0 +1,5 @@
1
+ import type { Outcome, PublishingIo } from './publishing.ts';
2
+ export declare const DATA_USAGE = "app data tables\napp data list <table>\napp data add <table> --row '{\"name\":\"Gold chair\",\"price\":8}' (or --file rows.json, a JSON array)\napp data update <table> <id> --set '{\"price\":14}'\napp data remove <table> <id>\napp data send --asked \"<the owner's request, in their words>\"\napp data status\napp data clear";
3
+ type DataOptions = ReadonlyMap<string, string | true>;
4
+ export declare function data(io: PublishingIo, root: string, [action, table, id]: readonly (string | undefined)[], options: DataOptions): Promise<Outcome>;
5
+ export {};
@@ -0,0 +1,273 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { CHANGE_SET_ROWS, DASHBOARD_URL, changeSet, dataChange, databaseRow, databaseRows, databaseSchema } from "../publishWire.js";
5
+ import { readProjectFile } from "../vite/projectFile.js";
6
+ import { callJson, failure, isOutcome, sayHost, signedIn, sleep } from "./publishing.js";
7
+ const STAGED = join('.dev', 'data-changes.json');
8
+ const WAIT_MS = 90_000;
9
+ const POLL_MS = 3000;
10
+ const MAX_PAGES = 50;
11
+ const staged = z.array(dataChange);
12
+ export const DATA_USAGE = `app data tables
13
+ app data list <table>
14
+ app data add <table> --row '{"name":"Gold chair","price":8}' (or --file rows.json, a JSON array)
15
+ app data update <table> <id> --set '{"price":14}'
16
+ app data remove <table> <id>
17
+ app data send --asked "<the owner's request, in their words>"
18
+ app data status
19
+ app data clear`;
20
+ function stagedPath(root) {
21
+ return join(root, STAGED);
22
+ }
23
+ function readStaged(root) {
24
+ const path = stagedPath(root);
25
+ if (!existsSync(path))
26
+ return [];
27
+ const parsed = staged.safeParse(JSON.parse(readFileSync(path, 'utf8')));
28
+ return parsed.success ? parsed.data : [];
29
+ }
30
+ function writeStaged(root, changes) {
31
+ const path = stagedPath(root);
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ writeFileSync(path, `${JSON.stringify(changes, null, 2)}\n`);
34
+ }
35
+ function refusal(text, code = 1) {
36
+ return { code, data: { error: text }, text: `✗ ${text}` };
37
+ }
38
+ function valueOf(options, flag) {
39
+ const value = options.get(flag);
40
+ return typeof value === 'string' ? value : undefined;
41
+ }
42
+ function jsonOf(text, what) {
43
+ try {
44
+ return JSON.parse(text);
45
+ }
46
+ catch {
47
+ throw new Error(`${what} is not JSON: ${text.slice(0, 80)}`);
48
+ }
49
+ }
50
+ function rowsGiven(root, options) {
51
+ const row = valueOf(options, '--row');
52
+ const file = valueOf(options, '--file');
53
+ if (row !== undefined)
54
+ return [databaseRow.parse(jsonOf(row, '--row'))];
55
+ if (file === undefined)
56
+ throw new Error('Give the row with --row or --file');
57
+ const path = resolve(root, file);
58
+ if (!existsSync(path))
59
+ throw new Error(`There is no file at ${file}`);
60
+ return z.array(databaseRow).parse(jsonOf(readFileSync(path, 'utf8'), file));
61
+ }
62
+ function pendingLines(changes) {
63
+ return changes.map((change) => {
64
+ if (change.op === 'add')
65
+ return ` + ${change.table} ${JSON.stringify(change.row)}`;
66
+ if (change.op === 'remove')
67
+ return ` − ${change.table} ${change.id}`;
68
+ return ` ~ ${change.table} ${change.id} ${JSON.stringify(change.after)}`;
69
+ });
70
+ }
71
+ async function liveRows(io, name, table) {
72
+ const rows = [];
73
+ let after = null;
74
+ for (let page = 0; page < MAX_PAGES; page += 1) {
75
+ const query = after === null ? '' : `?after=${encodeURIComponent(after)}`;
76
+ const result = await signedIn(io, `app data list ${table}`, () => callJson(`/api/apps/${name}/database/${table}${query}`, databaseRows));
77
+ if (isOutcome(result))
78
+ return result;
79
+ rows.push(...result.rows);
80
+ after = result.next;
81
+ if (after === null)
82
+ break;
83
+ }
84
+ return rows;
85
+ }
86
+ async function tables(io, name) {
87
+ const result = await signedIn(io, 'app data tables', () => callJson(`/api/apps/${name}/database`, databaseSchema));
88
+ if (isOutcome(result))
89
+ return result;
90
+ if (result.status === 'outdated') {
91
+ return refusal('Publish once with the latest Pattern to see the live data');
92
+ }
93
+ const lines = result.tables
94
+ .filter((table) => !table.isBuiltIn)
95
+ .map((table) => {
96
+ const properties = Object.keys(Object(Reflect.get(table.schema, 'properties')));
97
+ return ` ${table.name} ${table.rowCount} rows columns: ${properties.join(', ')}`;
98
+ });
99
+ return {
100
+ code: 0,
101
+ data: { name, tables: result.tables },
102
+ text: [`Live tables of ${name}`, ...lines].join('\n')
103
+ };
104
+ }
105
+ async function list(io, name, table) {
106
+ const rows = await liveRows(io, name, table);
107
+ if (isOutcome(rows))
108
+ return rows;
109
+ return {
110
+ code: 0,
111
+ data: { name, table, rows },
112
+ text: [
113
+ `${rows.length} live ${table} rows on ${name}`,
114
+ ...rows.map((row) => ` ${JSON.stringify(row)}`)
115
+ ].join('\n')
116
+ };
117
+ }
118
+ function stage(root, added) {
119
+ const changes = [...readStaged(root), ...added];
120
+ if (changes.length > CHANGE_SET_ROWS) {
121
+ return refusal(`That's more than ${CHANGE_SET_ROWS} changes. Send these first with app data send, then add the rest.`);
122
+ }
123
+ writeStaged(root, changes);
124
+ return {
125
+ code: 0,
126
+ data: { staged: changes },
127
+ text: [
128
+ `✓ ${changes.length} change${changes.length === 1 ? '' : 's'} ready to send. Nothing on the live site has changed.`,
129
+ ...pendingLines(changes),
130
+ ' next add any other changes, then app data send --asked "<their request, in their words>"'
131
+ ].join('\n')
132
+ };
133
+ }
134
+ async function before(io, name, table, id) {
135
+ const rows = await liveRows(io, name, table);
136
+ if (isOutcome(rows))
137
+ return rows;
138
+ const row = rows.find((candidate) => candidate['id'] === id);
139
+ if (row === undefined)
140
+ throw new Error(`There is no ${table} row with id ${id}`);
141
+ return row;
142
+ }
143
+ function decisionText(set, link) {
144
+ if (set.status === 'waiting') {
145
+ return {
146
+ code: 3,
147
+ data: { changeSet: set },
148
+ text: `… Still waiting for the owner to approve "${set.title}" at ${link}.\n next run app data status again: it waits for the decision`
149
+ };
150
+ }
151
+ if (set.status === 'applied') {
152
+ const outcomes = set.outcomes ?? [];
153
+ const missed = outcomes
154
+ .map((outcome, index) => ({ outcome, change: set.changes[index] }))
155
+ .filter(({ outcome }) => outcome.status !== 'applied');
156
+ return {
157
+ code: missed.length === 0 ? 0 : 1,
158
+ data: { changeSet: set },
159
+ text: [
160
+ `✓ Approved: ${set.title}. ${outcomes.length - missed.length} of ${outcomes.length} changes are live.`,
161
+ ...missed.map(({ outcome, change }) => ` ${outcome.status} ${change?.table ?? ''} ${change?.op ?? ''}: ${outcome.reason ?? ''}`)
162
+ ].join('\n')
163
+ };
164
+ }
165
+ const words = {
166
+ declined: 'The owner chose Not yet. Nothing changed on the live site.',
167
+ expired: 'This request expired or a newer one replaced it. Nothing changed.',
168
+ undone: 'The owner approved this, then undid it.'
169
+ }[set.status];
170
+ return { code: 1, data: { changeSet: set }, text: `✗ ${words}` };
171
+ }
172
+ async function waitForDecision(io, name, id) {
173
+ const link = `${DASHBOARD_URL}/sites/${name}/database`;
174
+ const deadline = Date.now() + WAIT_MS;
175
+ for (;;) {
176
+ const set = await signedIn(io, 'app data status', () => callJson(`/api/apps/${name}/changes/${id}`, changeSet));
177
+ if (isOutcome(set))
178
+ return set;
179
+ if (set.status !== 'waiting' || Date.now() > deadline) {
180
+ return decisionText(set, link);
181
+ }
182
+ await sleep(POLL_MS);
183
+ }
184
+ }
185
+ async function send(io, root, name, asked) {
186
+ const changes = readStaged(root);
187
+ if (changes.length === 0) {
188
+ return refusal('There are no changes to send. Use app data add, update or remove first.');
189
+ }
190
+ if (asked === undefined || asked.trim() === '') {
191
+ return refusal('Say what the owner asked for with --asked "<their words>". They see it beside Approve.', 2);
192
+ }
193
+ const set = await signedIn(io, 'app data send', () => callJson(`/api/apps/${name}/changes`, changeSet, {
194
+ method: 'POST',
195
+ headers: { 'content-type': 'application/json' },
196
+ body: JSON.stringify({ asked: asked.trim(), changes })
197
+ }));
198
+ if (isOutcome(set))
199
+ return set;
200
+ rmSync(stagedPath(root), { force: true });
201
+ const link = `${DASHBOARD_URL}/sites/${name}/database`;
202
+ io.say(`✓ Sent "${set.title}" to the owner. They also got an email.`);
203
+ io.say(` next tell them: "I've sent the changes to your Pattern dashboard. Open ${link} and press Approve." This waits for their decision.`);
204
+ return waitForDecision(io, name, set.id);
205
+ }
206
+ async function status(io, root, name) {
207
+ const sets = await signedIn(io, 'app data status', () => callJson(`/api/apps/${name}/changes`, z.array(changeSet)));
208
+ if (isOutcome(sets))
209
+ return sets;
210
+ const latest = sets[0];
211
+ const pending = readStaged(root);
212
+ if (latest === undefined) {
213
+ return {
214
+ code: 0,
215
+ data: { staged: pending },
216
+ text: pending.length === 0
217
+ ? 'Nothing has been sent for approval yet.'
218
+ : [
219
+ `${pending.length} changes ready to send:`,
220
+ ...pendingLines(pending)
221
+ ].join('\n')
222
+ };
223
+ }
224
+ return latest.status === 'waiting'
225
+ ? waitForDecision(io, name, latest.id)
226
+ : decisionText(latest, `${DASHBOARD_URL}/sites/${name}/database`);
227
+ }
228
+ export async function data(io, root, [action, table, id], options) {
229
+ try {
230
+ const { name } = readProjectFile(root);
231
+ if (action === 'clear') {
232
+ rmSync(stagedPath(root), { force: true });
233
+ return {
234
+ code: 0,
235
+ data: { staged: [] },
236
+ text: '✓ Cleared the changes that were ready to send'
237
+ };
238
+ }
239
+ sayHost(io);
240
+ if (action === 'tables')
241
+ return await tables(io, name);
242
+ if (action === 'send')
243
+ return await send(io, root, name, valueOf(options, '--asked'));
244
+ if (action === 'status')
245
+ return await status(io, root, name);
246
+ if (table === undefined)
247
+ return refusal(`Say which table\n${DATA_USAGE}`, 2);
248
+ if (action === 'list')
249
+ return await list(io, name, table);
250
+ if (action === 'add') {
251
+ return stage(root, rowsGiven(root, options).map((row) => ({ op: 'add', table, row })));
252
+ }
253
+ if (id === undefined)
254
+ return refusal(`Say which row, by its id\n${DATA_USAGE}`, 2);
255
+ const current = await before(io, name, table, id);
256
+ if (isOutcome(current))
257
+ return current;
258
+ if (action === 'remove') {
259
+ return stage(root, [{ op: 'remove', table, id, before: current }]);
260
+ }
261
+ if (action === 'update') {
262
+ const set = valueOf(options, '--set');
263
+ if (set === undefined)
264
+ return refusal(`Give the new values with --set '{...}'`, 2);
265
+ const after = databaseRow.parse(jsonOf(set, '--set'));
266
+ return stage(root, [{ op: 'update', table, id, before: current, after }]);
267
+ }
268
+ return refusal(`Unknown data command: ${action ?? ''}\n${DATA_USAGE}`, 2);
269
+ }
270
+ catch (error) {
271
+ return failure(error);
272
+ }
273
+ }
@@ -1,5 +1,6 @@
1
+ import { z } from 'zod';
1
2
  import type { StarterContent } from '../publishWire.ts';
2
- type Outcome = {
3
+ export type Outcome = {
3
4
  code: number;
4
5
  data: Record<string, unknown>;
5
6
  text: string;
@@ -14,10 +15,16 @@ export type PublishingIo = {
14
15
  build: () => Promise<Outcome>;
15
16
  sdkVersion: string;
16
17
  };
18
+ export declare function dashboard(): string;
19
+ export declare function sayHost(io: PublishingIo): void;
20
+ export declare function callJson<Value>(path: string, schema: z.ZodType<Value>, init?: RequestInit): Promise<Value>;
21
+ export declare function sleep(ms: number): Promise<unknown>;
22
+ export declare function signedIn<Result>(io: PublishingIo, again: string, work: () => Promise<Result>): Promise<Result | Outcome>;
23
+ export declare function isOutcome(value: unknown): value is Outcome;
24
+ export declare function failure(error: unknown, data?: Record<string, unknown>): Outcome;
17
25
  export declare function login(io: PublishingIo): Promise<Outcome>;
18
26
  export declare function pull(io: PublishingIo, name: string | undefined, cwd: string, setupCode: string | undefined): Promise<Outcome>;
19
27
  export declare function publish(io: PublishingIo, root: string, isStarter: boolean, message: string | undefined, starter: StarterContent | undefined): Promise<Outcome>;
20
28
  export declare function addLiveMedia(io: PublishingIo, root: string, form: FormData, again: string): Promise<Outcome>;
21
29
  export declare function versions(io: PublishingIo, root: string): Promise<Outcome>;
22
30
  export declare function restore(io: PublishingIo, root: string, number: string | undefined): Promise<Outcome>;
23
- export {};
@@ -48,10 +48,10 @@ const STEP_WORDS = {
48
48
  'sign-in': 'Turning on sign-in',
49
49
  publish: 'Publishing the site'
50
50
  };
51
- function dashboard() {
51
+ export function dashboard() {
52
52
  return (process.env['PATTERN_DASHBOARD_URL'] ?? DASHBOARD_URL).replace(/\/$/, '');
53
53
  }
54
- function sayHost(io) {
54
+ export function sayHost(io) {
55
55
  io.say(` via ${new URL(dashboard()).host}`);
56
56
  }
57
57
  const credentials = z.record(z.string(), z.object({ cookie: z.string() }));
@@ -114,7 +114,7 @@ async function call(path, init = {}) {
114
114
  }
115
115
  return response;
116
116
  }
117
- async function callJson(path, schema, init = {}) {
117
+ export async function callJson(path, schema, init = {}) {
118
118
  const response = await call(path, init);
119
119
  const body = await response.json().catch(() => null);
120
120
  if (!response.ok) {
@@ -130,7 +130,7 @@ function labelOfThisMachine() {
130
130
  const name = named.status === 0 ? named.stdout.trim() : '';
131
131
  return (name === '' ? 'this computer' : name).slice(0, 80);
132
132
  }
133
- function sleep(ms) {
133
+ export function sleep(ms) {
134
134
  return new Promise((done) => setTimeout(done, ms));
135
135
  }
136
136
  async function waitForApproval(secret) {
@@ -195,7 +195,7 @@ async function useSetupCode(io, code) {
195
195
  }
196
196
  io.say('✗ That setup code has expired or was already used');
197
197
  }
198
- async function signedIn(io, again, work) {
198
+ export async function signedIn(io, again, work) {
199
199
  if (savedCookie() === undefined) {
200
200
  const outcome = await signIn(io, again);
201
201
  if (outcome.code !== 0)
@@ -213,10 +213,10 @@ async function signedIn(io, again, work) {
213
213
  return work();
214
214
  }
215
215
  }
216
- function isOutcome(value) {
216
+ export function isOutcome(value) {
217
217
  return typeof value === 'object' && value !== null && 'text' in value;
218
218
  }
219
- function failure(error, data = {}) {
219
+ export function failure(error, data = {}) {
220
220
  const message = error instanceof TypeError && error.message === 'fetch failed'
221
221
  ? `Couldn’t reach ${dashboard()}. Check the internet connection and try again.`
222
222
  : error instanceof Error
@@ -0,0 +1,4 @@
1
+ export declare function isNewer(candidate: string, current: string): boolean;
2
+ export declare function latestPattern(fresh?: boolean): Promise<string | undefined>;
3
+ export declare function newerPattern(current: string): Promise<string | undefined>;
4
+ export declare function updateNote(latest: string, current: string): string;
@@ -0,0 +1,68 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { z } from 'zod';
5
+ const LATEST_URL = 'https://registry.npmjs.org/@trypattern/sdk/latest';
6
+ const CACHE_FILE = join(homedir(), '.config', 'pattern', 'latest-sdk.json');
7
+ const CHECK_MS = 6 * 3_600_000;
8
+ const TIMEOUT_MS = 1500;
9
+ const cached = z.object({ version: z.string(), checkedAt: z.number() });
10
+ const published = z.object({ version: z.string() });
11
+ function partsOf(version) {
12
+ return (version
13
+ .split('-')[0]
14
+ ?.split('.')
15
+ .map((part) => Number.parseInt(part, 10) || 0) ?? [0]);
16
+ }
17
+ export function isNewer(candidate, current) {
18
+ const next = partsOf(candidate);
19
+ const now = partsOf(current);
20
+ for (let index = 0; index < Math.max(next.length, now.length); index += 1) {
21
+ const difference = (next[index] ?? 0) - (now[index] ?? 0);
22
+ if (difference !== 0)
23
+ return difference > 0;
24
+ }
25
+ return false;
26
+ }
27
+ function readCache() {
28
+ if (!existsSync(CACHE_FILE))
29
+ return undefined;
30
+ try {
31
+ const parsed = cached.safeParse(JSON.parse(readFileSync(CACHE_FILE, 'utf8')));
32
+ return parsed.success ? parsed.data : undefined;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ export async function latestPattern(fresh = false) {
39
+ const remembered = readCache();
40
+ if (!fresh &&
41
+ remembered !== undefined &&
42
+ Date.now() - remembered.checkedAt < CHECK_MS) {
43
+ return remembered.version;
44
+ }
45
+ try {
46
+ const response = await fetch(LATEST_URL, {
47
+ signal: AbortSignal.timeout(TIMEOUT_MS)
48
+ });
49
+ const body = published.safeParse(await response.json());
50
+ if (!response.ok || !body.success)
51
+ return remembered?.version;
52
+ mkdirSync(dirname(CACHE_FILE), { recursive: true });
53
+ writeFileSync(CACHE_FILE, JSON.stringify({ version: body.data.version, checkedAt: Date.now() }));
54
+ return body.data.version;
55
+ }
56
+ catch {
57
+ return remembered?.version;
58
+ }
59
+ }
60
+ export async function newerPattern(current) {
61
+ if (process.env['PATTERN_NO_UPDATE_CHECK'] !== undefined)
62
+ return undefined;
63
+ const latest = await latestPattern();
64
+ return latest !== undefined && isNewer(latest, current) ? latest : undefined;
65
+ }
66
+ export function updateNote(latest, current) {
67
+ return ` update Pattern ${latest} is out (this project has ${current}). Run app update now, then app check. Publish it straight away if nothing else is in progress, otherwise with the next publish, so the live site gets it too. Tell the owner in a few words.`;
68
+ }
@@ -3,7 +3,7 @@ import type { Placement } from './Placement.ts';
3
3
  type BadgeProps = {
4
4
  children: ReactNode;
5
5
  variant?: 'primary' | 'tertiary' | 'outline' | 'transparent';
6
- tone?: 'neutral' | 'brand' | 'positive' | 'danger';
6
+ tone?: 'neutral' | 'brand' | 'positive' | 'warning' | 'danger';
7
7
  size?: 'sm' | 'md';
8
8
  style?: Placement;
9
9
  };
@@ -8,6 +8,7 @@ const toneKeys = {
8
8
  neutral: 'Neutral',
9
9
  brand: 'Brand',
10
10
  positive: 'Positive',
11
+ warning: 'Warning',
11
12
  danger: 'Danger'
12
13
  };
13
14
  const styles = stylex.create({
@@ -46,6 +47,10 @@ const looks = stylex.create({
46
47
  color: colors.onPositiveSolidForeground,
47
48
  backgroundColor: colors.positiveSolid
48
49
  },
50
+ primaryWarning: {
51
+ color: colors.onWarningSolidForeground,
52
+ backgroundColor: colors.warningSolid
53
+ },
49
54
  primaryDanger: {
50
55
  color: colors.onDangerSolidForeground,
51
56
  backgroundColor: colors.dangerSolid
@@ -62,6 +67,10 @@ const looks = stylex.create({
62
67
  color: colors.onPositiveSubtleForeground,
63
68
  backgroundColor: colors.positiveSubtle
64
69
  },
70
+ tertiaryWarning: {
71
+ color: colors.onWarningSubtleForeground,
72
+ backgroundColor: colors.warningSubtle
73
+ },
65
74
  tertiaryDanger: {
66
75
  color: colors.dangerText,
67
76
  backgroundColor: colors.dangerSubtleHover
@@ -81,6 +90,11 @@ const looks = stylex.create({
81
90
  backgroundColor: 'transparent',
82
91
  boxShadow: `inset 0 0 0 ${borders.thin} ${colors.positiveBorderDefault}`
83
92
  },
93
+ outlineWarning: {
94
+ color: colors.warningForeground,
95
+ backgroundColor: 'transparent',
96
+ boxShadow: `inset 0 0 0 ${borders.thin} ${colors.warningBorderDefault}`
97
+ },
84
98
  outlineDanger: {
85
99
  color: colors.dangerForeground,
86
100
  backgroundColor: 'transparent',
@@ -98,6 +112,10 @@ const looks = stylex.create({
98
112
  color: colors.positiveForeground,
99
113
  backgroundColor: 'transparent'
100
114
  },
115
+ transparentWarning: {
116
+ color: colors.warningForeground,
117
+ backgroundColor: 'transparent'
118
+ },
101
119
  transparentDanger: {
102
120
  color: colors.dangerForeground,
103
121
  backgroundColor: 'transparent'
@@ -6,7 +6,7 @@ import type { Size } from './Size.ts';
6
6
  export type ButtonProps = {
7
7
  children: ReactNode;
8
8
  variant?: 'primary' | 'secondary' | 'tertiary' | 'transparent';
9
- tone?: 'neutral' | 'brand' | 'danger';
9
+ tone?: 'neutral' | 'brand' | 'positive' | 'danger';
10
10
  size?: Size;
11
11
  to?: AppPath;
12
12
  href?: string;