@trypattern/sdk 0.1.0 → 0.1.1
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/api.d.ts +17 -7
- package/dist/api.js +34 -8
- package/dist/app.d.ts +12 -3
- package/dist/app.js +49 -7
- package/dist/appInfo.d.ts +35 -0
- package/dist/appInfo.js +67 -0
- package/dist/appTitle.d.ts +6 -0
- package/dist/appTitle.js +3 -0
- package/dist/builtins/AppSettings.d.ts +12 -0
- package/dist/builtins/AppSettings.js +31 -0
- package/dist/builtins/Toasts.d.ts +1 -1
- package/dist/builtins/Toasts.js +2 -1
- package/dist/cli/app.js +91 -14
- package/dist/cli/publishing.d.ts +21 -0
- package/dist/cli/publishing.js +493 -0
- package/dist/clock.d.ts +6 -0
- package/dist/clock.js +31 -0
- package/dist/components/AppFrame.js +3 -3
- package/dist/components/Button.d.ts +2 -1
- package/dist/components/Button.js +6 -3
- package/dist/components/CopyButton.d.ts +11 -0
- package/dist/components/CopyButton.js +16 -0
- package/dist/components/Divider.d.ts +2 -1
- package/dist/components/Divider.js +19 -4
- package/dist/components/IconButton.js +2 -2
- package/dist/components/Link.d.ts +8 -3
- package/dist/components/Link.js +11 -2
- package/dist/components/ListRow.js +2 -2
- package/dist/components/Menu.js +2 -2
- package/dist/components/RadioGroup.d.ts +18 -0
- package/dist/components/RadioGroup.js +85 -0
- package/dist/components/SegmentedControl.js +2 -2
- package/dist/components/Sidebar.js +2 -3
- package/dist/components/SignInScreen.js +3 -3
- package/dist/components/Switch.js +2 -1
- package/dist/components/controlLook.d.ts +10 -0
- package/dist/components/controlLook.js +14 -1
- package/dist/components/icons.d.ts +4 -3
- package/dist/components/icons.js +9 -3
- package/dist/components/interaction.d.ts +1 -1
- package/dist/flows/accessFlows.js +19 -3
- package/dist/flows/browser.d.ts +3 -1
- package/dist/flows/browser.js +6 -3
- package/dist/flows/flowBrowser.d.ts +3 -1
- package/dist/flows/flowBrowser.js +4 -2
- package/dist/flows/flows.d.ts +6 -0
- package/dist/flows/flows.js +32 -5
- package/dist/hooks/appContext.d.ts +2 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -2
- package/dist/publishWire.d.ts +271 -0
- package/dist/publishWire.js +168 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +2 -0
- package/dist/registered.d.ts +6 -0
- package/dist/registered.js +2 -0
- package/dist/rules/checkAllFiles.js +10 -2
- package/dist/rules/lintPlugin.js +2 -0
- package/dist/rules/messages.d.ts +2 -2
- package/dist/rules/messages.js +2 -2
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +1 -0
- package/dist/server/auth.js +4 -1
- package/dist/server/createServer.d.ts +48 -3
- package/dist/server/createServer.js +25 -5
- package/dist/server/env.d.ts +2 -2
- package/dist/server/index.d.ts +6 -1
- package/dist/server/index.js +5 -1
- package/dist/server/services.d.ts +9 -0
- package/dist/server/services.js +47 -0
- package/dist/server/session.d.ts +1 -0
- package/dist/server/session.js +11 -0
- package/dist/server/settings.d.ts +49 -0
- package/dist/server/settings.js +25 -0
- package/dist/server/signInWire.d.ts +1 -0
- package/dist/server/signInWire.js +2 -1
- package/dist/serviceClients.d.ts +5 -0
- package/dist/serviceClients.js +70 -0
- package/dist/services.d.ts +53 -0
- package/dist/services.js +30 -0
- package/dist/start.js +30 -6
- package/dist/store.d.ts +5 -3
- package/dist/store.js +13 -3
- package/dist/table.d.ts +1 -1
- package/dist/vite/localWorker.d.ts +1 -1
- package/dist/vite/localWorker.js +5 -4
- package/dist/vite/projectFile.d.ts +9 -0
- package/dist/vite/projectFile.js +17 -0
- package/dist/vite/vite.d.ts +2 -1
- package/dist/vite/vite.js +10 -3
- package/package.json +5 -1
- package/presets/register.d.ts +2 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir, hostname, userInfo } from 'node:os';
|
|
4
|
+
import { basename, dirname, extname, join, matchesGlob, relative } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { DASHBOARD_URL, UPLOAD, appSummary, isPrivatePath, loginPoll, loginStarted, nameProblem, publishSummary, sourcePathProblem } from "../publishWire.js";
|
|
7
|
+
import { appInfo } from "../appTitle.js";
|
|
8
|
+
import { PROJECT_FILE, readProjectFile } from "../vite/projectFile.js";
|
|
9
|
+
const CONFIG_DIR = join(homedir(), '.config', 'pattern');
|
|
10
|
+
const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
|
|
11
|
+
const PENDING_FILE = join(CONFIG_DIR, 'pending-login.json');
|
|
12
|
+
const LOGIN_MINUTES = 10;
|
|
13
|
+
const POLL_MS = 2000;
|
|
14
|
+
const LOGIN_WAIT_MS = 120_000;
|
|
15
|
+
const PUBLISH_WAIT_MS = 300_000;
|
|
16
|
+
const SESSION_COOKIE = 'pattern_session';
|
|
17
|
+
const IGNORED_IN_EMPTY_FOLDER = new Set(['.DS_Store', '.claude']);
|
|
18
|
+
const MIME_TYPES = {
|
|
19
|
+
'.html': 'text/html',
|
|
20
|
+
'.js': 'text/javascript',
|
|
21
|
+
'.mjs': 'text/javascript',
|
|
22
|
+
'.css': 'text/css',
|
|
23
|
+
'.json': 'application/json',
|
|
24
|
+
'.map': 'application/json',
|
|
25
|
+
'.webmanifest': 'application/manifest+json',
|
|
26
|
+
'.svg': 'image/svg+xml',
|
|
27
|
+
'.png': 'image/png',
|
|
28
|
+
'.jpg': 'image/jpeg',
|
|
29
|
+
'.jpeg': 'image/jpeg',
|
|
30
|
+
'.gif': 'image/gif',
|
|
31
|
+
'.webp': 'image/webp',
|
|
32
|
+
'.avif': 'image/avif',
|
|
33
|
+
'.ico': 'image/x-icon',
|
|
34
|
+
'.woff2': 'font/woff2',
|
|
35
|
+
'.woff': 'font/woff',
|
|
36
|
+
'.txt': 'text/plain',
|
|
37
|
+
'.xml': 'application/xml',
|
|
38
|
+
'.pdf': 'application/pdf',
|
|
39
|
+
'.mp4': 'video/mp4',
|
|
40
|
+
'.sql': 'application/sql'
|
|
41
|
+
};
|
|
42
|
+
const STEP_WORDS = {
|
|
43
|
+
reserve: 'Reserving the address',
|
|
44
|
+
database: 'Updating the database',
|
|
45
|
+
'sign-in': 'Turning on sign-in',
|
|
46
|
+
publish: 'Publishing the site'
|
|
47
|
+
};
|
|
48
|
+
function dashboard() {
|
|
49
|
+
return (process.env['PATTERN_DASHBOARD_URL'] ?? DASHBOARD_URL).replace(/\/$/, '');
|
|
50
|
+
}
|
|
51
|
+
const credentials = z.record(z.string(), z.object({ cookie: z.string() }));
|
|
52
|
+
const pendingLogin = z.object({
|
|
53
|
+
dashboard: z.string(),
|
|
54
|
+
secret: z.string(),
|
|
55
|
+
code: z.string(),
|
|
56
|
+
url: z.string(),
|
|
57
|
+
startedAt: z.number()
|
|
58
|
+
});
|
|
59
|
+
function readJson(path, schema) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = schema.safeParse(JSON.parse(readFileSync(path, 'utf8')));
|
|
62
|
+
return parsed.success ? parsed.data : undefined;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function writePrivate(path, value) {
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
70
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
function savedCookie() {
|
|
73
|
+
return readJson(CREDENTIALS_FILE, credentials)?.[dashboard()]?.cookie;
|
|
74
|
+
}
|
|
75
|
+
function saveCookie(cookie) {
|
|
76
|
+
const all = { ...readJson(CREDENTIALS_FILE, credentials) };
|
|
77
|
+
if (cookie === undefined)
|
|
78
|
+
delete all[dashboard()];
|
|
79
|
+
else
|
|
80
|
+
all[dashboard()] = { cookie };
|
|
81
|
+
writePrivate(CREDENTIALS_FILE, all);
|
|
82
|
+
}
|
|
83
|
+
class SignedOut extends Error {
|
|
84
|
+
}
|
|
85
|
+
async function call(path, init = {}) {
|
|
86
|
+
const cookie = savedCookie();
|
|
87
|
+
if (cookie === undefined)
|
|
88
|
+
throw new SignedOut();
|
|
89
|
+
const response = await fetch(`${dashboard()}${path}`, {
|
|
90
|
+
...init,
|
|
91
|
+
headers: {
|
|
92
|
+
...init.headers,
|
|
93
|
+
cookie,
|
|
94
|
+
origin: dashboard()
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
const renewed = response.headers
|
|
98
|
+
.getSetCookie()
|
|
99
|
+
.find((line) => line.startsWith(`${SESSION_COOKIE}=`));
|
|
100
|
+
if (renewed !== undefined) {
|
|
101
|
+
const pair = renewed.split(';')[0] ?? '';
|
|
102
|
+
if (pair !== `${SESSION_COOKIE}=`)
|
|
103
|
+
saveCookie(pair);
|
|
104
|
+
}
|
|
105
|
+
if (response.status === 401) {
|
|
106
|
+
saveCookie(undefined);
|
|
107
|
+
throw new SignedOut();
|
|
108
|
+
}
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
async function callJson(path, schema, init = {}) {
|
|
112
|
+
const response = await call(path, init);
|
|
113
|
+
const body = await response.json().catch(() => null);
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const error = Reflect.get(Object(body), 'error');
|
|
116
|
+
throw new Error(typeof error === 'string' ? error : `HTTP ${response.status}`);
|
|
117
|
+
}
|
|
118
|
+
return schema.parse(body);
|
|
119
|
+
}
|
|
120
|
+
function labelOfThisMachine() {
|
|
121
|
+
const machine = hostname().replace(/\.local$/, '');
|
|
122
|
+
return `${userInfo().username} on ${machine}`.slice(0, 80);
|
|
123
|
+
}
|
|
124
|
+
function sleep(ms) {
|
|
125
|
+
return new Promise((done) => setTimeout(done, ms));
|
|
126
|
+
}
|
|
127
|
+
async function waitForApproval(secret) {
|
|
128
|
+
const deadline = Date.now() + LOGIN_WAIT_MS;
|
|
129
|
+
while (Date.now() < deadline) {
|
|
130
|
+
const response = await fetch(`${dashboard()}/api/auth/cli/logins/${secret}`);
|
|
131
|
+
const poll = loginPoll.safeParse(await response.json().catch(() => null));
|
|
132
|
+
if (poll.success && poll.data.status === 'approved')
|
|
133
|
+
return poll.data.cookie;
|
|
134
|
+
await sleep(POLL_MS);
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
async function signIn(io, again) {
|
|
139
|
+
const pending = readJson(PENDING_FILE, pendingLogin);
|
|
140
|
+
const isFresh = pending !== undefined &&
|
|
141
|
+
pending.dashboard === dashboard() &&
|
|
142
|
+
Date.now() - pending.startedAt < LOGIN_MINUTES * 60_000;
|
|
143
|
+
if (pending !== undefined && isFresh) {
|
|
144
|
+
io.say(`Waiting for Allow on ${pending.url} (code ${pending.code})`);
|
|
145
|
+
const cookie = await waitForApproval(pending.secret);
|
|
146
|
+
if (cookie !== undefined) {
|
|
147
|
+
saveCookie(cookie);
|
|
148
|
+
rmSync(PENDING_FILE, { force: true });
|
|
149
|
+
return { code: 0, data: { signedIn: true }, text: '✓ Signed in' };
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
code: 3,
|
|
153
|
+
data: { waitingFor: 'approval', code: pending.code, url: pending.url },
|
|
154
|
+
text: `✗ Not allowed yet. The browser page ${pending.url} shows the code ${pending.code}.\n next once they click Allow there, run ${again} again`
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const response = await fetch(`${dashboard()}/api/auth/cli/logins`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'content-type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({ label: labelOfThisMachine() })
|
|
161
|
+
});
|
|
162
|
+
const started = loginStarted.parse(await response.json());
|
|
163
|
+
writePrivate(PENDING_FILE, {
|
|
164
|
+
dashboard: dashboard(),
|
|
165
|
+
...started,
|
|
166
|
+
startedAt: Date.now()
|
|
167
|
+
});
|
|
168
|
+
io.openUrl(started.url);
|
|
169
|
+
return {
|
|
170
|
+
code: 3,
|
|
171
|
+
data: { waitingFor: 'approval', code: started.code, url: started.url },
|
|
172
|
+
text: `Opened ${started.url} in the browser.\n code ${started.code}\n next tell them: "Check the code on the page is ${started.code}, then click Allow." Then run ${again} again.`
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async function signedIn(io, again, work) {
|
|
176
|
+
if (savedCookie() === undefined) {
|
|
177
|
+
const outcome = await signIn(io, again);
|
|
178
|
+
if (outcome.code !== 0)
|
|
179
|
+
return outcome;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return await work();
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (!(error instanceof SignedOut))
|
|
186
|
+
throw error;
|
|
187
|
+
const outcome = await signIn(io, again);
|
|
188
|
+
if (outcome.code !== 0)
|
|
189
|
+
return outcome;
|
|
190
|
+
return work();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function isOutcome(value) {
|
|
194
|
+
return typeof value === 'object' && value !== null && 'text' in value;
|
|
195
|
+
}
|
|
196
|
+
function failure(error, data = {}) {
|
|
197
|
+
const message = error instanceof TypeError && error.message === 'fetch failed'
|
|
198
|
+
? `Couldn’t reach ${dashboard()}. Check the internet connection and try again.`
|
|
199
|
+
: error instanceof Error
|
|
200
|
+
? error.message
|
|
201
|
+
: String(error);
|
|
202
|
+
return { code: 1, data: { ...data, error: message }, text: `✗ ${message}` };
|
|
203
|
+
}
|
|
204
|
+
export async function login(io) {
|
|
205
|
+
try {
|
|
206
|
+
if (savedCookie() !== undefined) {
|
|
207
|
+
const me = await call('/api/me').catch(() => undefined);
|
|
208
|
+
if (me?.ok === true) {
|
|
209
|
+
return {
|
|
210
|
+
code: 0,
|
|
211
|
+
data: { signedIn: true },
|
|
212
|
+
text: '✓ Already signed in'
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return await signIn(io, 'app login');
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return failure(error);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function isEmptyFolder(path) {
|
|
223
|
+
return readdirSync(path).every((entry) => IGNORED_IN_EMPTY_FOLDER.has(entry));
|
|
224
|
+
}
|
|
225
|
+
function commandLineToolsReady() {
|
|
226
|
+
return spawnSync('xcode-select', ['-p'], { stdio: 'ignore' }).status === 0;
|
|
227
|
+
}
|
|
228
|
+
export async function pull(io, name, cwd) {
|
|
229
|
+
if (name === undefined || nameProblem(name) !== undefined) {
|
|
230
|
+
return {
|
|
231
|
+
code: 2,
|
|
232
|
+
data: { error: 'missing name' },
|
|
233
|
+
text: '✗ Which site? app pull sundayparty'
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const target = isEmptyFolder(cwd) ? cwd : join(cwd, name);
|
|
237
|
+
if (target !== cwd && existsSync(target) && !isEmptyFolder(target)) {
|
|
238
|
+
return {
|
|
239
|
+
code: 1,
|
|
240
|
+
data: { error: 'folder not empty', folder: target },
|
|
241
|
+
text: `✗ ${target} already has files in it. Pull into an empty folder.`
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const result = await signedIn(io, `app pull ${name}`, async () => {
|
|
246
|
+
const app = await callJson(`/api/apps/${name}`, appSummary);
|
|
247
|
+
const response = await call(`/api/apps/${name}/source`);
|
|
248
|
+
if (!response.ok)
|
|
249
|
+
throw new Error(`Couldn’t download ${name}`);
|
|
250
|
+
const number = response.headers.get('x-pattern-publish') ?? '?';
|
|
251
|
+
const live = await fetch(`${app.url}/api/app`)
|
|
252
|
+
.then((reply) => reply.json())
|
|
253
|
+
.then((body) => appInfo.parse(body).title)
|
|
254
|
+
.catch(() => '');
|
|
255
|
+
const title = live === '' ? app.title : live;
|
|
256
|
+
return { app, title, number, form: await response.formData() };
|
|
257
|
+
});
|
|
258
|
+
if (isOutcome(result))
|
|
259
|
+
return result;
|
|
260
|
+
let files = 0;
|
|
261
|
+
for (const [key, value] of result.form.entries()) {
|
|
262
|
+
if (!key.startsWith(UPLOAD.source) || typeof value === 'string')
|
|
263
|
+
continue;
|
|
264
|
+
const path = key.slice(UPLOAD.source.length);
|
|
265
|
+
if (sourcePathProblem(path) !== undefined)
|
|
266
|
+
continue;
|
|
267
|
+
const destination = join(target, path);
|
|
268
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
269
|
+
writeFileSync(destination, Buffer.from(await value.arrayBuffer()));
|
|
270
|
+
files += 1;
|
|
271
|
+
}
|
|
272
|
+
writeFileSync(join(target, PROJECT_FILE), `${JSON.stringify({ name, title: result.title }, null, 2)}\n`);
|
|
273
|
+
io.say(`✓ Downloaded ${files} files of version ${result.number}`);
|
|
274
|
+
const install = await io.run('pnpm', ['install'], target);
|
|
275
|
+
if (install.code !== 0) {
|
|
276
|
+
return failure(new Error(`pnpm install failed\n${install.output}`));
|
|
277
|
+
}
|
|
278
|
+
io.say('✓ Installed');
|
|
279
|
+
const setup = await io.run(join(target, 'app'), ['setup'], target);
|
|
280
|
+
if (setup.code !== 0)
|
|
281
|
+
io.say(setup.output.trimEnd());
|
|
282
|
+
if (commandLineToolsReady() && !existsSync(join(target, '.git'))) {
|
|
283
|
+
await io.run('git', ['init', '-q'], target);
|
|
284
|
+
await io.run('git', ['add', '-A'], target);
|
|
285
|
+
await io.run('git', ['commit', '-q', '-m', `Version ${result.number} from onpattern.app`], target);
|
|
286
|
+
}
|
|
287
|
+
const moved = target === cwd ? '' : `\n folder ${target} (open this folder in Claude)`;
|
|
288
|
+
return {
|
|
289
|
+
code: 0,
|
|
290
|
+
data: { name, folder: target, version: result.number, files },
|
|
291
|
+
text: `✓ ${result.title} is on this computer${moved}\n next read AGENTS.md, then app start`
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
return failure(error);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function filesUnder(root) {
|
|
299
|
+
if (!existsSync(root))
|
|
300
|
+
return [];
|
|
301
|
+
return readdirSync(root, { recursive: true, withFileTypes: true })
|
|
302
|
+
.filter((entry) => entry.isFile())
|
|
303
|
+
.map((entry) => relative(root, join(entry.parentPath, entry.name)));
|
|
304
|
+
}
|
|
305
|
+
function ignoredPatterns(root) {
|
|
306
|
+
const path = join(root, '.gitignore');
|
|
307
|
+
if (!existsSync(path))
|
|
308
|
+
return [];
|
|
309
|
+
return readFileSync(path, 'utf8')
|
|
310
|
+
.split('\n')
|
|
311
|
+
.map((line) => line.trim())
|
|
312
|
+
.filter((line) => line !== '' && !line.startsWith('#'))
|
|
313
|
+
.map((line) => line.replace(/^\//, '').replace(/\/$/, ''));
|
|
314
|
+
}
|
|
315
|
+
function sourceFiles(root) {
|
|
316
|
+
const git = existsSync(join(root, '.git')) || commandLineToolsReady()
|
|
317
|
+
? spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root, encoding: 'utf8' })
|
|
318
|
+
: undefined;
|
|
319
|
+
const listed = git !== undefined && git.status === 0
|
|
320
|
+
? git.stdout.split('\0').filter((path) => path !== '')
|
|
321
|
+
: filesUnder(root).filter((path) => {
|
|
322
|
+
const patterns = ignoredPatterns(root);
|
|
323
|
+
return !patterns.some((pattern) => matchesGlob(path, pattern) ||
|
|
324
|
+
path.split('/').some((part) => matchesGlob(part, pattern)));
|
|
325
|
+
});
|
|
326
|
+
return listed.filter((path) => sourcePathProblem(path) === undefined &&
|
|
327
|
+
existsSync(join(root, path)) &&
|
|
328
|
+
statSync(join(root, path)).isFile());
|
|
329
|
+
}
|
|
330
|
+
function fileBlob(path, contents) {
|
|
331
|
+
return new Blob([contents ?? readFileSync(path)], {
|
|
332
|
+
type: MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
function workerDirectory(dist) {
|
|
336
|
+
return readdirSync(dist, { withFileTypes: true })
|
|
337
|
+
.filter((entry) => entry.isDirectory() && entry.name !== 'client')
|
|
338
|
+
.map((entry) => join(dist, entry.name))
|
|
339
|
+
.filter((path) => existsSync(join(path, 'wrangler.json')))
|
|
340
|
+
.toSorted((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
341
|
+
}
|
|
342
|
+
function starterPackageJson(path, sdkVersion) {
|
|
343
|
+
return readFileSync(path, 'utf8').replace(/"@trypattern\/sdk": "workspace:[^"]*"/, `"@trypattern/sdk": "${sdkVersion}"`);
|
|
344
|
+
}
|
|
345
|
+
function uploadForm(root, io, isStarter) {
|
|
346
|
+
const dist = join(root, 'dist');
|
|
347
|
+
const worker = workerDirectory(dist);
|
|
348
|
+
if (worker === undefined)
|
|
349
|
+
throw new Error('The build left no worker in dist/');
|
|
350
|
+
const wrangler = JSON.parse(readFileSync(join(worker, 'wrangler.json'), 'utf8'));
|
|
351
|
+
const form = new FormData();
|
|
352
|
+
form.append(UPLOAD.compatibilityDate, String(Reflect.get(Object(wrangler), 'compatibility_date') ?? ''));
|
|
353
|
+
form.append(UPLOAD.sdkVersion, io.sdkVersion);
|
|
354
|
+
for (const file of readdirSync(worker)) {
|
|
355
|
+
if (!file.endsWith('.js') || isPrivatePath(file))
|
|
356
|
+
continue;
|
|
357
|
+
form.append(`${UPLOAD.worker}${file}`, fileBlob(join(worker, file)), file);
|
|
358
|
+
}
|
|
359
|
+
for (const path of filesUnder(join(dist, 'client'))) {
|
|
360
|
+
if (basename(path) === '.assetsignore' || isPrivatePath(path))
|
|
361
|
+
continue;
|
|
362
|
+
form.append(`${UPLOAD.client}${path}`, fileBlob(join(dist, 'client', path)), basename(path));
|
|
363
|
+
}
|
|
364
|
+
const migrations = join(root, 'server', 'migrations');
|
|
365
|
+
for (const file of existsSync(migrations) ? readdirSync(migrations) : []) {
|
|
366
|
+
if (!file.endsWith('.sql'))
|
|
367
|
+
continue;
|
|
368
|
+
form.append(`${UPLOAD.migrations}${file}`, fileBlob(join(migrations, file)), file);
|
|
369
|
+
}
|
|
370
|
+
const sources = sourceFiles(root);
|
|
371
|
+
for (const path of sources) {
|
|
372
|
+
const full = join(root, path);
|
|
373
|
+
const contents = isStarter && path === 'package.json'
|
|
374
|
+
? starterPackageJson(full, io.sdkVersion)
|
|
375
|
+
: undefined;
|
|
376
|
+
form.append(`${UPLOAD.source}${path}`, fileBlob(full, contents), basename(path));
|
|
377
|
+
}
|
|
378
|
+
return { form, sources: sources.length };
|
|
379
|
+
}
|
|
380
|
+
async function followPublish(io, name, started) {
|
|
381
|
+
let publish = started;
|
|
382
|
+
let shown;
|
|
383
|
+
const deadline = Date.now() + PUBLISH_WAIT_MS;
|
|
384
|
+
while (publish.status === 'running' && Date.now() < deadline) {
|
|
385
|
+
if (publish.step !== shown) {
|
|
386
|
+
io.say(` ${STEP_WORDS[publish.step]}…`);
|
|
387
|
+
shown = publish.step;
|
|
388
|
+
}
|
|
389
|
+
await sleep(POLL_MS);
|
|
390
|
+
publish = await callJson(`/api/apps/${name}/publishes/${publish.number}`, publishSummary);
|
|
391
|
+
}
|
|
392
|
+
const data = { name, version: publish.number, status: publish.status };
|
|
393
|
+
if (publish.status === 'live') {
|
|
394
|
+
return {
|
|
395
|
+
code: 0,
|
|
396
|
+
data,
|
|
397
|
+
text: `✓ Version ${publish.number} is live at https://${name}.onpattern.app`
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (publish.status === 'failed') {
|
|
401
|
+
return {
|
|
402
|
+
code: 1,
|
|
403
|
+
data: { ...data, error: publish.error },
|
|
404
|
+
text: `✗ Version ${publish.number} didn’t go live: ${publish.error ?? 'no details'}\n The site still runs the previous version.`
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
code: 1,
|
|
409
|
+
data,
|
|
410
|
+
text: `✗ Version ${publish.number} is still publishing after ${PUBLISH_WAIT_MS / 60_000} minutes\n next app versions shows how it ends`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
export async function publish(io, root, isStarter) {
|
|
414
|
+
try {
|
|
415
|
+
const { name } = readProjectFile(root);
|
|
416
|
+
const built = await io.build();
|
|
417
|
+
if (built.code !== 0)
|
|
418
|
+
return built;
|
|
419
|
+
io.say('✓ Built');
|
|
420
|
+
const { form, sources } = uploadForm(root, io, isStarter);
|
|
421
|
+
io.say(`✓ Packed the build and ${sources} source files`);
|
|
422
|
+
const again = isStarter ? 'app publish --starter' : 'app publish';
|
|
423
|
+
const result = await signedIn(io, again, async () => {
|
|
424
|
+
if (isStarter) {
|
|
425
|
+
await callJson('/api/starters', z.object({ key: z.string() }), {
|
|
426
|
+
method: 'POST',
|
|
427
|
+
body: form
|
|
428
|
+
});
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
return callJson(`/api/apps/${name}/publishes`, publishSummary, {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
body: form
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
if (isOutcome(result))
|
|
437
|
+
return result;
|
|
438
|
+
if (result === undefined) {
|
|
439
|
+
return {
|
|
440
|
+
code: 0,
|
|
441
|
+
data: { starter: io.sdkVersion },
|
|
442
|
+
text: `✓ Published the starter for SDK ${io.sdkVersion}`
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return await followPublish(io, name, result);
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
return failure(error);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
export async function versions(io, root) {
|
|
452
|
+
try {
|
|
453
|
+
const { name } = readProjectFile(root);
|
|
454
|
+
const result = await signedIn(io, 'app versions', () => callJson(`/api/apps/${name}/publishes`, z.array(publishSummary)));
|
|
455
|
+
if (isOutcome(result))
|
|
456
|
+
return result;
|
|
457
|
+
const lines = result.map((item) => {
|
|
458
|
+
const what = item.restoredFrom !== null
|
|
459
|
+
? `restored version ${item.restoredFrom}`
|
|
460
|
+
: item.isStarter
|
|
461
|
+
? 'starter site'
|
|
462
|
+
: `published by ${item.byEmail}`;
|
|
463
|
+
return ` ${String(item.number).padStart(3)} ${item.status.padEnd(7)} ${item.startedAt.slice(0, 16).replace('T', ' ')} ${what}`;
|
|
464
|
+
});
|
|
465
|
+
return {
|
|
466
|
+
code: 0,
|
|
467
|
+
data: { name, versions: result },
|
|
468
|
+
text: [`Versions of ${name} (newest first)`, ...lines].join('\n')
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
return failure(error);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
export async function restore(io, root, number) {
|
|
476
|
+
if (number === undefined || !/^\d+$/.test(number)) {
|
|
477
|
+
return {
|
|
478
|
+
code: 2,
|
|
479
|
+
data: { error: 'missing version' },
|
|
480
|
+
text: '✗ Which version? app restore 3 (app versions lists them)'
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const { name } = readProjectFile(root);
|
|
485
|
+
const result = await signedIn(io, `app restore ${number}`, () => callJson(`/api/apps/${name}/publishes/${number}/restore`, publishSummary, { method: 'POST' }));
|
|
486
|
+
if (isOutcome(result))
|
|
487
|
+
return result;
|
|
488
|
+
return await followPublish(io, name, result);
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
return failure(error);
|
|
492
|
+
}
|
|
493
|
+
}
|
package/dist/clock.d.ts
ADDED
package/dist/clock.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function realClock() {
|
|
2
|
+
const repeats = new Set();
|
|
3
|
+
return {
|
|
4
|
+
every(ms, run) {
|
|
5
|
+
const id = setInterval(run, ms);
|
|
6
|
+
repeats.add(run);
|
|
7
|
+
return () => {
|
|
8
|
+
clearInterval(id);
|
|
9
|
+
repeats.delete(run);
|
|
10
|
+
};
|
|
11
|
+
},
|
|
12
|
+
tick() {
|
|
13
|
+
for (const run of [...repeats])
|
|
14
|
+
run();
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function manualClock() {
|
|
19
|
+
const repeats = new Set();
|
|
20
|
+
return {
|
|
21
|
+
every(_ms, run) {
|
|
22
|
+
const entry = () => run();
|
|
23
|
+
repeats.add(entry);
|
|
24
|
+
return () => repeats.delete(entry);
|
|
25
|
+
},
|
|
26
|
+
tick() {
|
|
27
|
+
for (const run of [...repeats])
|
|
28
|
+
run();
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as stylex from '@stylexjs/stylex';
|
|
3
3
|
import { breakpoints, colors, radii, shadows, space } from "../theme/tokens.stylex.js";
|
|
4
|
-
import {
|
|
4
|
+
import { useModel } from "../hooks/appContext.js";
|
|
5
5
|
import { FrameContext } from "./frame.js";
|
|
6
6
|
import { Scrim } from "./Scrim.js";
|
|
7
7
|
import { Sidebar } from "./Sidebar.js";
|
|
8
8
|
import { useAnimatesOnChange } from "../hooks/useAnimatesOnChange.js";
|
|
9
9
|
export function AppFrame({ links, workspaceMenu = [], isSidebarToggled, onToggleSidebar, onCloseSidebar, onSearch, children }) {
|
|
10
|
-
const
|
|
10
|
+
const appSettings = useModel('appSettings');
|
|
11
11
|
const account = useModel('account');
|
|
12
12
|
const menu = account.user === null
|
|
13
13
|
? workspaceMenu
|
|
@@ -17,7 +17,7 @@ export function AppFrame({ links, workspaceMenu = [], isSidebarToggled, onToggle
|
|
|
17
17
|
{ id: 'signOut', label: 'Log out', onSelect: account.signOut }
|
|
18
18
|
];
|
|
19
19
|
const isSidebarAnimating = useAnimatesOnChange(isSidebarToggled, SIDEBAR_ANIMATION_MS);
|
|
20
|
-
return (_jsx(FrameContext, { value: { onToggleSidebar }, children: _jsxs("div", { ...stylex.props(styles.frame), children: [_jsx(Sidebar, { workspace:
|
|
20
|
+
return (_jsx(FrameContext, { value: { onToggleSidebar }, children: _jsxs("div", { ...stylex.props(styles.frame), children: [_jsx(Sidebar, { workspace: appSettings.title, workspaceMenu: menu, links: links, isToggled: isSidebarToggled, isAnimating: isSidebarAnimating, onFollowLink: onCloseSidebar, onSearch: onSearch === undefined
|
|
21
21
|
? undefined
|
|
22
22
|
: () => {
|
|
23
23
|
onCloseSidebar();
|
|
@@ -9,6 +9,7 @@ type ButtonProps = {
|
|
|
9
9
|
tone?: 'neutral' | 'brand' | 'danger';
|
|
10
10
|
size?: Size;
|
|
11
11
|
to?: AppPath;
|
|
12
|
+
href?: string;
|
|
12
13
|
icon?: IconName;
|
|
13
14
|
trailingIcon?: IconName;
|
|
14
15
|
shortcut?: readonly string[];
|
|
@@ -19,5 +20,5 @@ type ButtonProps = {
|
|
|
19
20
|
onClick?: () => void;
|
|
20
21
|
style?: Placement;
|
|
21
22
|
};
|
|
22
|
-
export declare function Button({ children, variant, tone, size, to, icon, trailingIcon, shortcut, type, disabled, isPending, isFullWidth, onClick, style }: ButtonProps): import("react").JSX.Element;
|
|
23
|
+
export declare function Button({ children, variant, tone, size, to, href, icon, trailingIcon, shortcut, type, disabled, isPending, isFullWidth, onClick, style }: ButtonProps): import("react").JSX.Element;
|
|
23
24
|
export {};
|
|
@@ -3,19 +3,22 @@ import { Button as BaseButton } from '@base-ui/react/button';
|
|
|
3
3
|
import { Link as RouterLink } from 'react-router';
|
|
4
4
|
import * as stylex from '@stylexjs/stylex';
|
|
5
5
|
import { colors, controls, fontWeights, motion, shadows } from "../theme/tokens.stylex.js";
|
|
6
|
-
import { controlSizes } from "./controlLook.js";
|
|
6
|
+
import { controlSizes, focusRing } from "./controlLook.js";
|
|
7
7
|
import { Icon } from "./Icon.js";
|
|
8
8
|
import { interact, textOf } from "./interaction.js";
|
|
9
9
|
import { Kbd } from "./Kbd.js";
|
|
10
10
|
import { Spinner } from "./Spinner.js";
|
|
11
|
-
export function Button({ children, variant = 'primary', tone = 'neutral', size = 'md', to, icon, trailingIcon, shortcut, type = 'button', disabled = false, isPending = false, isFullWidth = false, onClick, style }) {
|
|
11
|
+
export function Button({ children, variant = 'primary', tone = 'neutral', size = 'md', to, href, icon, trailingIcon, shortcut, type = 'button', disabled = false, isPending = false, isFullWidth = false, onClick, style }) {
|
|
12
12
|
const isDisabled = disabled || isPending;
|
|
13
13
|
const report = () => {
|
|
14
14
|
interact({ primitive: 'Button', event: 'click', label: textOf(children) });
|
|
15
15
|
onClick?.();
|
|
16
16
|
};
|
|
17
|
-
const className = stylex.props(styles.button, controlSizes[size], icon !== undefined && leadingIconSizes[size], trailingIcon !== undefined && trailingIconSizes[size], looks[`${variant}${toneKeys[tone]}`], !isDisabled && hovers[`${variant}${toneKeys[tone]}`], isDisabled && styles.disabled, isDisabled && variant === 'transparent' && styles.disabledTransparent, isFullWidth && styles.fullWidth, style).className;
|
|
17
|
+
const className = stylex.props(styles.button, controlSizes[size], icon !== undefined && leadingIconSizes[size], trailingIcon !== undefined && trailingIconSizes[size], looks[`${variant}${toneKeys[tone]}`], !isDisabled && hovers[`${variant}${toneKeys[tone]}`], isDisabled && styles.disabled, isDisabled && variant === 'transparent' && styles.disabledTransparent, isFullWidth && styles.fullWidth, focusRing.visible, style).className;
|
|
18
18
|
const content = (_jsxs("span", { ...stylex.props(styles.stack), children: [_jsxs("span", { ...stylex.props(styles.content, isPending && styles.hidden), children: [icon === undefined ? null : _jsx(Icon, { name: icon, size: size }), _jsx("span", { ...stylex.props(styles.label, shortcut !== undefined && styles.labelBeforeShortcut), children: children }), trailingIcon === undefined ? null : (_jsx(Icon, { name: trailingIcon, size: size })), shortcut === undefined ? null : _jsx(Kbd, { keys: shortcut })] }), isPending ? (_jsx("span", { "aria-hidden": "true", ...stylex.props(styles.pending), children: _jsx(Spinner, { size: size }) })) : null] }));
|
|
19
|
+
if (href !== undefined) {
|
|
20
|
+
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", onClick: report, className: className, children: content }));
|
|
21
|
+
}
|
|
19
22
|
if (to !== undefined) {
|
|
20
23
|
return (_jsx(RouterLink, { to: to, onClick: report, className: className, children: content }));
|
|
21
24
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Placement } from './Placement.ts';
|
|
2
|
+
import type { Size } from './Size.ts';
|
|
3
|
+
type CopyButtonProps = {
|
|
4
|
+
text: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
variant?: 'primary' | 'secondary' | 'tertiary' | 'transparent';
|
|
7
|
+
size?: Size;
|
|
8
|
+
style?: Placement;
|
|
9
|
+
};
|
|
10
|
+
export declare function CopyButton({ text, label, variant, size, style }: CopyButtonProps): import("react").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Button } from "./Button.js";
|
|
4
|
+
export function CopyButton({ text, label = 'Copy', variant = 'primary', size = 'sm', style }) {
|
|
5
|
+
const [isCopied, setIsCopied] = useState(false);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (!isCopied)
|
|
8
|
+
return undefined;
|
|
9
|
+
const timer = setTimeout(() => setIsCopied(false), COPIED_MS);
|
|
10
|
+
return () => clearTimeout(timer);
|
|
11
|
+
}, [isCopied]);
|
|
12
|
+
return (_jsx(Button, { variant: variant, size: size, icon: isCopied ? 'check' : 'copy', onClick: () => {
|
|
13
|
+
void navigator.clipboard.writeText(text).then(() => setIsCopied(true));
|
|
14
|
+
}, ...(style === undefined ? {} : { style }), children: isCopied ? 'Copied' : label }));
|
|
15
|
+
}
|
|
16
|
+
const COPIED_MS = 1500;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Placement } from './Placement.ts';
|
|
2
2
|
type DividerProps = {
|
|
3
|
+
label?: string;
|
|
3
4
|
style?: Placement;
|
|
4
5
|
};
|
|
5
|
-
export declare function Divider({ style }: DividerProps): import("react").JSX.Element;
|
|
6
|
+
export declare function Divider({ label, style }: DividerProps): import("react").JSX.Element;
|
|
6
7
|
export {};
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Separator } from '@base-ui/react/separator';
|
|
3
3
|
import * as stylex from '@stylexjs/stylex';
|
|
4
|
-
import { borders, colors } from "../theme/tokens.stylex.js";
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { borders, colors, space } from "../theme/tokens.stylex.js";
|
|
5
|
+
import { Text } from "./Text.js";
|
|
6
|
+
export function Divider({ label, style }) {
|
|
7
|
+
if (label === undefined) {
|
|
8
|
+
return (_jsx(Separator, { className: stylex.props(styles.divider, style).className }));
|
|
9
|
+
}
|
|
10
|
+
return (_jsxs("div", { ...stylex.props(styles.labelled, style), children: [_jsx(Separator, { className: stylex.props(styles.divider, styles.grow).className }), _jsx(Text, { variant: "caption", tone: "tertiary", children: label }), _jsx(Separator, { className: stylex.props(styles.divider, styles.grow).className })] }));
|
|
7
11
|
}
|
|
8
12
|
const styles = stylex.create({
|
|
9
13
|
divider: {
|
|
@@ -11,5 +15,16 @@ const styles = stylex.create({
|
|
|
11
15
|
width: '100%',
|
|
12
16
|
height: borders.hairline,
|
|
13
17
|
backgroundColor: colors.divider
|
|
18
|
+
},
|
|
19
|
+
labelled: {
|
|
20
|
+
display: 'flex',
|
|
21
|
+
gap: space.md,
|
|
22
|
+
alignItems: 'center',
|
|
23
|
+
width: '100%'
|
|
24
|
+
},
|
|
25
|
+
grow: {
|
|
26
|
+
flexGrow: 1,
|
|
27
|
+
flexShrink: 1,
|
|
28
|
+
width: 'auto'
|
|
14
29
|
}
|
|
15
30
|
});
|