@trypattern/sdk 0.0.0-stage → 0.1.0
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 +127 -2
- package/dist/access.d.ts +31 -0
- package/dist/access.js +72 -0
- package/dist/actions.d.ts +57 -0
- package/dist/actions.js +198 -0
- package/dist/api.d.ts +20 -0
- package/dist/api.js +34 -0
- package/dist/app.d.ts +106 -0
- package/dist/app.js +224 -0
- package/dist/appearance.d.ts +7 -0
- package/dist/appearance.js +2 -0
- package/dist/auth/sessionApi.d.ts +41 -0
- package/dist/auth/sessionApi.js +158 -0
- package/dist/auth/sessionStore.d.ts +20 -0
- package/dist/auth/sessionStore.js +40 -0
- package/dist/auth/user.d.ts +11 -0
- package/dist/auth/user.js +9 -0
- package/dist/builtins/Account.d.ts +25 -0
- package/dist/builtins/Account.js +15 -0
- package/dist/builtins/Palette.d.ts +29 -0
- package/dist/builtins/Palette.js +63 -0
- package/dist/builtins/SignIn.d.ts +28 -0
- package/dist/builtins/SignIn.js +60 -0
- package/dist/builtins/Toasts.d.ts +32 -0
- package/dist/builtins/Toasts.js +24 -0
- package/dist/builtins/actions.d.ts +2 -0
- package/dist/builtins/actions.js +45 -0
- package/dist/cache.d.ts +24 -0
- package/dist/cache.js +112 -0
- package/dist/cli/app.d.ts +2 -0
- package/dist/cli/app.js +1329 -0
- package/dist/cli/screenshot.d.ts +1 -0
- package/dist/cli/screenshot.js +96 -0
- package/dist/cli/tools.d.ts +12 -0
- package/dist/cli/tools.js +35 -0
- package/dist/components/AppFrame.d.ts +14 -0
- package/dist/components/AppFrame.js +64 -0
- package/dist/components/AppearancePicker.d.ts +1 -0
- package/dist/components/AppearancePicker.js +12 -0
- package/dist/components/Avatar.d.ts +9 -0
- package/dist/components/Avatar.js +63 -0
- package/dist/components/Badge.d.ts +11 -0
- package/dist/components/Badge.js +87 -0
- package/dist/components/Button.d.ts +23 -0
- package/dist/components/Button.js +249 -0
- package/dist/components/Card.d.ts +12 -0
- package/dist/components/Card.js +38 -0
- package/dist/components/CommandMenu.d.ts +23 -0
- package/dist/components/CommandMenu.js +214 -0
- package/dist/components/CommandPalette.d.ts +1 -0
- package/dist/components/CommandPalette.js +7 -0
- package/dist/components/Content.d.ts +7 -0
- package/dist/components/Content.js +24 -0
- package/dist/components/ContextMenu.d.ts +8 -0
- package/dist/components/ContextMenu.js +23 -0
- package/dist/components/Dialog.d.ts +11 -0
- package/dist/components/Dialog.js +81 -0
- package/dist/components/Divider.d.ts +6 -0
- package/dist/components/Divider.js +15 -0
- package/dist/components/EmptyState.d.ts +8 -0
- package/dist/components/EmptyState.js +24 -0
- package/dist/components/Form.d.ts +9 -0
- package/dist/components/Form.js +23 -0
- package/dist/components/Icon.d.ts +10 -0
- package/dist/components/Icon.js +22 -0
- package/dist/components/IconButton.d.ts +14 -0
- package/dist/components/IconButton.js +53 -0
- package/dist/components/Kbd.d.ts +6 -0
- package/dist/components/Kbd.js +67 -0
- package/dist/components/Link.d.ts +9 -0
- package/dist/components/Link.js +35 -0
- package/dist/components/ListRow.d.ts +12 -0
- package/dist/components/ListRow.js +62 -0
- package/dist/components/LoadingRows.d.ts +5 -0
- package/dist/components/LoadingRows.js +6 -0
- package/dist/components/Menu.d.ts +12 -0
- package/dist/components/Menu.js +33 -0
- package/dist/components/MenuItems.d.ts +21 -0
- package/dist/components/MenuItems.js +32 -0
- package/dist/components/Placement.d.ts +21 -0
- package/dist/components/Placement.js +1 -0
- package/dist/components/Popup.d.ts +8 -0
- package/dist/components/Popup.js +43 -0
- package/dist/components/PopupItem.d.ts +8 -0
- package/dist/components/PopupItem.js +51 -0
- package/dist/components/PropertyRow.d.ts +7 -0
- package/dist/components/PropertyRow.js +23 -0
- package/dist/components/Row.d.ts +16 -0
- package/dist/components/Row.js +27 -0
- package/dist/components/Screen.d.ts +13 -0
- package/dist/components/Screen.js +56 -0
- package/dist/components/Scrim.d.ts +8 -0
- package/dist/components/Scrim.js +40 -0
- package/dist/components/ScrollArea.d.ts +15 -0
- package/dist/components/ScrollArea.js +13 -0
- package/dist/components/SegmentedControl.d.ts +17 -0
- package/dist/components/SegmentedControl.js +68 -0
- package/dist/components/Select.d.ts +17 -0
- package/dist/components/Select.js +107 -0
- package/dist/components/SettingsRow.d.ts +8 -0
- package/dist/components/SettingsRow.js +32 -0
- package/dist/components/Sidebar.d.ts +20 -0
- package/dist/components/Sidebar.js +138 -0
- package/dist/components/SignInScreen.d.ts +1 -0
- package/dist/components/SignInScreen.js +40 -0
- package/dist/components/Size.d.ts +1 -0
- package/dist/components/Size.js +1 -0
- package/dist/components/Skeleton.d.ts +8 -0
- package/dist/components/Skeleton.js +48 -0
- package/dist/components/Spinner.d.ts +6 -0
- package/dist/components/Spinner.js +54 -0
- package/dist/components/Stack.d.ts +14 -0
- package/dist/components/Stack.js +19 -0
- package/dist/components/Switch.d.ts +10 -0
- package/dist/components/Switch.js +71 -0
- package/dist/components/Text.d.ts +21 -0
- package/dist/components/Text.js +77 -0
- package/dist/components/TextField.d.ts +25 -0
- package/dist/components/TextField.js +68 -0
- package/dist/components/Toaster.d.ts +1 -0
- package/dist/components/Toaster.js +83 -0
- package/dist/components/Tooltip.d.ts +8 -0
- package/dist/components/Tooltip.js +43 -0
- package/dist/components/controlLook.d.ts +60 -0
- package/dist/components/controlLook.js +67 -0
- package/dist/components/frame.d.ts +4 -0
- package/dist/components/frame.js +2 -0
- package/dist/components/icons.d.ts +58 -0
- package/dist/components/icons.js +105 -0
- package/dist/components/interaction.d.ts +12 -0
- package/dist/components/interaction.js +22 -0
- package/dist/components/spacing.d.ts +118 -0
- package/dist/components/spacing.js +46 -0
- package/dist/components/tones.d.ts +28 -0
- package/dist/components/tones.js +12 -0
- package/dist/entityStore.d.ts +46 -0
- package/dist/entityStore.js +160 -0
- package/dist/flows/accessFlows.d.ts +9 -0
- package/dist/flows/accessFlows.js +151 -0
- package/dist/flows/browser.d.ts +32 -0
- package/dist/flows/browser.js +164 -0
- package/dist/flows/contactSheet.d.ts +1 -0
- package/dist/flows/contactSheet.js +63 -0
- package/dist/flows/flowBrowser.d.ts +23 -0
- package/dist/flows/flowBrowser.js +21 -0
- package/dist/flows/flowSetup.d.ts +3 -0
- package/dist/flows/flowSetup.js +35 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +148 -0
- package/dist/flows/screenshotName.d.ts +1 -0
- package/dist/flows/screenshotName.js +6 -0
- package/dist/gate.d.ts +8 -0
- package/dist/gate.js +52 -0
- package/dist/hooks/appContext.d.ts +14 -0
- package/dist/hooks/appContext.js +5 -0
- package/dist/hooks/appHooks.d.ts +21 -0
- package/dist/hooks/appHooks.js +24 -0
- package/dist/hooks/useAnimatesOnChange.d.ts +1 -0
- package/dist/hooks/useAnimatesOnChange.js +14 -0
- package/dist/hooks/useStoreState.d.ts +6 -0
- package/dist/hooks/useStoreState.js +5 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +19 -0
- package/dist/memoryApi.d.ts +19 -0
- package/dist/memoryApi.js +42 -0
- package/dist/model.d.ts +94 -0
- package/dist/model.js +243 -0
- package/dist/page.d.ts +11 -0
- package/dist/page.js +9 -0
- package/dist/platform.d.ts +1 -0
- package/dist/platform.js +5 -0
- package/dist/react.d.ts +46 -0
- package/dist/react.js +38 -0
- package/dist/registered.d.ts +54 -0
- package/dist/registered.js +17 -0
- package/dist/routes.d.ts +38 -0
- package/dist/routes.js +21 -0
- package/dist/rules/checkAllFiles.d.ts +1 -0
- package/dist/rules/checkAllFiles.js +741 -0
- package/dist/rules/checkApiRouting.d.ts +1 -0
- package/dist/rules/checkApiRouting.js +112 -0
- package/dist/rules/lintPlugin.d.ts +252 -0
- package/dist/rules/lintPlugin.js +1157 -0
- package/dist/rules/messages.d.ts +48 -0
- package/dist/rules/messages.js +48 -0
- package/dist/rules/themeTokens.d.ts +6 -0
- package/dist/rules/themeTokens.js +64 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +1 -0
- package/dist/server/auth.d.ts +115 -0
- package/dist/server/auth.js +131 -0
- package/dist/server/authService.d.ts +9 -0
- package/dist/server/authService.js +50 -0
- package/dist/server/createServer.d.ts +357 -0
- package/dist/server/createServer.js +62 -0
- package/dist/server/env.d.ts +15 -0
- package/dist/server/env.js +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.js +5 -0
- package/dist/server/session.d.ts +28 -0
- package/dist/server/session.js +54 -0
- package/dist/server/sessions.d.ts +92 -0
- package/dist/server/sessions.js +47 -0
- package/dist/server/signInWire.d.ts +52 -0
- package/dist/server/signInWire.js +22 -0
- package/dist/server/tables.d.ts +242 -0
- package/dist/server/tables.js +246 -0
- package/dist/server/users.d.ts +189 -0
- package/dist/server/users.js +49 -0
- package/dist/shortcuts.d.ts +3 -0
- package/dist/shortcuts.js +40 -0
- package/dist/start.d.ts +20 -0
- package/dist/start.js +121 -0
- package/dist/state.d.ts +14 -0
- package/dist/state.js +17 -0
- package/dist/store.d.ts +43 -0
- package/dist/store.js +198 -0
- package/dist/subscribers.d.ts +5 -0
- package/dist/subscribers.js +13 -0
- package/dist/table.d.ts +143 -0
- package/dist/table.js +91 -0
- package/dist/tables.d.ts +77 -0
- package/dist/tables.js +216 -0
- package/dist/theme/reset.css +84 -0
- package/dist/theme/scales.d.ts +76 -0
- package/dist/theme/scales.js +82 -0
- package/dist/theme/tokens.stylex.d.ts +218 -0
- package/dist/theme/tokens.stylex.js +233 -0
- package/dist/vite/commandBridge.d.ts +2 -0
- package/dist/vite/commandBridge.js +46 -0
- package/dist/vite/designManifest.d.ts +25 -0
- package/dist/vite/designManifest.js +191 -0
- package/dist/vite/localWorker.d.ts +4 -0
- package/dist/vite/localWorker.js +59 -0
- package/dist/vite/rootAlias.d.ts +2 -0
- package/dist/vite/rootAlias.js +15 -0
- package/dist/vite/vite.d.ts +7 -0
- package/dist/vite/vite.js +88 -0
- package/dist/wire.d.ts +7 -0
- package/dist/wire.js +1 -0
- package/package.json +99 -3
- package/presets/lintPlugin.js +7 -0
- package/presets/oxlint.json +167 -0
- package/presets/register.d.ts +19 -0
- package/presets/server.d.ts +2 -0
- package/presets/tsconfig.app.json +29 -0
- package/presets/tsconfig.server.json +27 -0
package/dist/cli/app.js
ADDED
|
@@ -0,0 +1,1329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
5
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { delimiter, dirname, extname, join, relative, resolve } from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { resetLocalDatabase } from "../vite/localWorker.js";
|
|
17
|
+
import { toolBin } from "./tools.js";
|
|
18
|
+
function dependsOnPattern(directory) {
|
|
19
|
+
try {
|
|
20
|
+
const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
|
|
21
|
+
const dependencies = typeof manifest === 'object' && manifest !== null
|
|
22
|
+
? Reflect.get(manifest, 'dependencies')
|
|
23
|
+
: undefined;
|
|
24
|
+
return (typeof dependencies === 'object' &&
|
|
25
|
+
dependencies !== null &&
|
|
26
|
+
'@trypattern/sdk' in dependencies);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function findRoot() {
|
|
33
|
+
const given = process.env['PATTERN_ROOT'];
|
|
34
|
+
if (given !== undefined)
|
|
35
|
+
return resolve(given);
|
|
36
|
+
let directory = process.cwd();
|
|
37
|
+
while (!dependsOnPattern(directory)) {
|
|
38
|
+
const parent = dirname(directory);
|
|
39
|
+
if (parent === directory) {
|
|
40
|
+
process.stderr.write('app: no Pattern project here. Run app inside a project that depends on @trypattern/sdk.\n');
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
directory = parent;
|
|
44
|
+
}
|
|
45
|
+
return directory;
|
|
46
|
+
}
|
|
47
|
+
const ROOT = findRoot();
|
|
48
|
+
const SDK_SOURCE = resolve(import.meta.dirname, '..');
|
|
49
|
+
const SDK_EXTENSION = extname(import.meta.filename);
|
|
50
|
+
const FORMATTED_PATHS = [
|
|
51
|
+
'src',
|
|
52
|
+
'server',
|
|
53
|
+
'flows',
|
|
54
|
+
'.claude',
|
|
55
|
+
'*.{ts,json,jsonc,html,md}'
|
|
56
|
+
];
|
|
57
|
+
const LINT_CONFIG = resolve(SDK_SOURCE, '..', 'presets', 'oxlint.json');
|
|
58
|
+
const MIGRATIONS_DIR = join('server', 'migrations');
|
|
59
|
+
const DEV_DIR = join(ROOT, '.dev');
|
|
60
|
+
const LOG_FILE = join(DEV_DIR, 'dev.log');
|
|
61
|
+
const STATE_FILE = join(DEV_DIR, 'server.json');
|
|
62
|
+
const START_TIMEOUT_MS = 20_000;
|
|
63
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
64
|
+
const LOG_TAIL_LINES = 30;
|
|
65
|
+
const SHIM_DIR = join(homedir(), '.local', 'bin');
|
|
66
|
+
const SHIM_PATH = join(SHIM_DIR, 'app');
|
|
67
|
+
const SHIM_MARKER_LINE = 'PATTERN_APP_SHIM=1';
|
|
68
|
+
const LAUNCHER_MARKER_LINE = 'PATTERN_APP_LAUNCHER=1';
|
|
69
|
+
const LEGACY_SHIM_MARKERS = ['# starter-kit-app-shim', 'JIG_APP_SHIM=1'];
|
|
70
|
+
const SCREENSHOTS_DIR = join(DEV_DIR, 'screenshots');
|
|
71
|
+
const FLOW_SCREENSHOTS_DIR = join(SCREENSHOTS_DIR, 'flows');
|
|
72
|
+
const CONTACT_SHEET = join(SCREENSHOTS_DIR, 'contact-sheet.png');
|
|
73
|
+
const SCREENSHOT_WORKER = join(SDK_SOURCE, 'cli', `screenshot${SDK_EXTENSION}`);
|
|
74
|
+
const VISUAL_URL_ENV = 'PATTERN_VISUAL_URL';
|
|
75
|
+
const GLOBAL_FLAGS = ['--json', '--help', '-h'];
|
|
76
|
+
const VALUE_FLAGS = new Set(['--commands', '--name', '--as']);
|
|
77
|
+
const SHIM_SOURCE = `#!/bin/sh
|
|
78
|
+
${SHIM_MARKER_LINE}
|
|
79
|
+
dir=$PWD
|
|
80
|
+
while :; do
|
|
81
|
+
if [ -f "$dir/app" ] && [ -x "$dir/app" ] && grep -qx '${LAUNCHER_MARKER_LINE}' "$dir/app" 2>/dev/null; then
|
|
82
|
+
exec "$dir/app" "$@"
|
|
83
|
+
fi
|
|
84
|
+
[ "$dir" = "/" ] && break
|
|
85
|
+
dir=$(dirname "$dir")
|
|
86
|
+
done
|
|
87
|
+
echo "app: no app found in $PWD or its parents" >&2
|
|
88
|
+
exit 1
|
|
89
|
+
`;
|
|
90
|
+
const CHECK_STEPS = [
|
|
91
|
+
{
|
|
92
|
+
name: 'typecheck',
|
|
93
|
+
does: 'tsc over src/, flows/, schema.ts and seed.ts',
|
|
94
|
+
run: () => [toolBin('tsc'), '-p', '.']
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'typecheck-server',
|
|
98
|
+
does: 'tsc over server/',
|
|
99
|
+
run: () => [toolBin('tsc'), '-p', 'server']
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'lint',
|
|
103
|
+
does: "oxlint with Pattern's rules",
|
|
104
|
+
run: () => [toolBin('oxlint'), '--config', LINT_CONFIG, '--deny-warnings']
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'pattern-rules',
|
|
108
|
+
does: 'no comments in any file, and every file where it belongs',
|
|
109
|
+
run: () => [join(SDK_SOURCE, 'rules', `checkAllFiles${SDK_EXTENSION}`)]
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'format',
|
|
113
|
+
does: 'prettier --check',
|
|
114
|
+
run: () => [toolBin('prettier'), '--check', ...FORMATTED_PATHS]
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'flows',
|
|
118
|
+
does: 'every flow in flows/, headless',
|
|
119
|
+
run: () => [toolBin('vitest'), 'run']
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'build',
|
|
123
|
+
does: 'vite build',
|
|
124
|
+
run: () => [toolBin('vite'), 'build']
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'api-routing',
|
|
128
|
+
does: '/api reaches the worker and pages reach the app, in dev and in the build',
|
|
129
|
+
run: () => [join(SDK_SOURCE, 'rules', `checkApiRouting${SDK_EXTENSION}`)]
|
|
130
|
+
}
|
|
131
|
+
];
|
|
132
|
+
const DEV_USER = 'ada@acme.test';
|
|
133
|
+
const COMMANDS = {
|
|
134
|
+
start: {
|
|
135
|
+
summary: 'Start the dev server in the background; wait until it responds, print its URL',
|
|
136
|
+
details: `Spawns Vite detached (port and strictPort set in vite.config.ts), logging to
|
|
137
|
+
${LOG_FILE} (truncated on each start) and recording the pid in ${STATE_FILE}.
|
|
138
|
+
Waits up to ${START_TIMEOUT_MS / 1000}s for the URL to respond. Fails if another process also answers on
|
|
139
|
+
the port at 127.0.0.1 or ::1: strictPort misses this on macOS, because Node listens with SO_REUSEADDR,
|
|
140
|
+
so Vite can bind ::1 while another server holds 127.0.0.1 or the wildcard address.
|
|
141
|
+
If already running, prints the URL.
|
|
142
|
+
Exit 0 when the server responds; otherwise prints the last ${LOG_TAIL_LINES} log lines and exits 1.
|
|
143
|
+
Opens the URL in the default browser only when run in an interactive terminal (not with --json,
|
|
144
|
+
not under CI, not when piped, so agents never trigger it). --no-open skips it.`,
|
|
145
|
+
run: startAndOpen,
|
|
146
|
+
flags: ['--no-open']
|
|
147
|
+
},
|
|
148
|
+
stop: {
|
|
149
|
+
summary: 'Stop the dev server started by `app start` (idempotent)',
|
|
150
|
+
details: `Sends SIGTERM to the server's whole process group (SIGKILL after ${STOP_TIMEOUT_MS / 1000}s)
|
|
151
|
+
and removes the pid file. Exits 0 if nothing is running. Stale pid files are removed.`,
|
|
152
|
+
run: stop
|
|
153
|
+
},
|
|
154
|
+
status: {
|
|
155
|
+
summary: 'Show whether the dev server is running, with its URL and log; exit 1 if stopped',
|
|
156
|
+
details: `Reports the dev server state and the log path (${LOG_FILE}).
|
|
157
|
+
Stale pid files (process gone or not Vite) are detected and removed.`,
|
|
158
|
+
run: status
|
|
159
|
+
},
|
|
160
|
+
check: {
|
|
161
|
+
summary: 'Typecheck + lint + Pattern rules + format + flows + build; exit non-zero on any failure',
|
|
162
|
+
details: `The definition of done. Runs every step even if an earlier one fails, so one run shows
|
|
163
|
+
all problems. Prints ✓/✗ per step; a failing step shows its output and how to rerun just it.
|
|
164
|
+
app check <step> runs one step. Steps:
|
|
165
|
+
${CHECK_STEPS.map((step) => ` ${step.name.padEnd(17)} ${step.does}`).join('\n')}`,
|
|
166
|
+
run: check,
|
|
167
|
+
takesArgument: true
|
|
168
|
+
},
|
|
169
|
+
fix: {
|
|
170
|
+
summary: 'Apply every automatic fix: formatting and auto-fixable lint rules',
|
|
171
|
+
details: `Runs prettier --write over the project and oxlint --fix with Pattern's rules, then
|
|
172
|
+
app check format and app check lint pass for anything a tool can fix by itself.`,
|
|
173
|
+
run: fix
|
|
174
|
+
},
|
|
175
|
+
reset: {
|
|
176
|
+
summary: 'Empty the local database and fill it again from seed.ts',
|
|
177
|
+
details: `Deletes every row in every table of the local database (signed-in users stay) and inserts
|
|
178
|
+
the rows in seed.ts again. app start only fills an empty table, so run this after changing seed.ts.
|
|
179
|
+
Works while the dev server runs; reload the page to see the new rows.`,
|
|
180
|
+
run: reset
|
|
181
|
+
},
|
|
182
|
+
migration: {
|
|
183
|
+
summary: 'Write a database migration for the tables in schema.ts',
|
|
184
|
+
details: `app migration add_due_dates
|
|
185
|
+
Compares the tables in schema.ts (plus Pattern's users and sessions tables) with server/migrations and writes the
|
|
186
|
+
SQL that moves the database from one to the other, named <number>_<name>.sql. app start applies new
|
|
187
|
+
migrations to the local database. Prints "No schema changes" when the migrations already match.`,
|
|
188
|
+
run: migration,
|
|
189
|
+
takesArgument: true
|
|
190
|
+
},
|
|
191
|
+
build: {
|
|
192
|
+
summary: 'Build the app for production into dist/',
|
|
193
|
+
details: 'Runs vite build: the browser app and the Cloudflare Worker that serves /api.',
|
|
194
|
+
run: build
|
|
195
|
+
},
|
|
196
|
+
send: {
|
|
197
|
+
summary: 'Send a command to the app open in your browser and print its snapshot',
|
|
198
|
+
details: `app send '{"type":"app.navigate","to":"/settings"}'
|
|
199
|
+
Runs the command in the running app (app start, with the page open) exactly as a click would,
|
|
200
|
+
then prints the app's snapshot (page, path, every model's and store's state). The same commands
|
|
201
|
+
drive flows/*.flow.ts. A command is a model's command() field, "<model>.<command>", declared in
|
|
202
|
+
src/models/*.ts with its input. An unknown command lists every valid one.`,
|
|
203
|
+
run: send,
|
|
204
|
+
takesArgument: true
|
|
205
|
+
},
|
|
206
|
+
screenshot: {
|
|
207
|
+
summary: 'Screenshot a path at desktop and compact sizes in headless Chrome; fail on browser errors',
|
|
208
|
+
details: `app screenshot /settings
|
|
209
|
+
app screenshot / --commands '[{"type":"shell.toggleSidebar"}]' --name sidebar-drawer
|
|
210
|
+
Opens <path> in headless Google Chrome, runs --commands once, in order (a JSON array of the
|
|
211
|
+
commands app send takes, the same ones app send and flows use), waits for fonts and an idle
|
|
212
|
+
network with motion off (prefers-reduced-motion), then shows the same page at the desktop and
|
|
213
|
+
compact (phone, sidebar as a drawer) sizes and saves ${pathFromCwd(SCREENSHOTS_DIR)}/<name>-desktop.png and
|
|
214
|
+
<name>-compact.png. --name defaults to the path: /settings gives settings, / gives index.
|
|
215
|
+
Signs in as ${DEV_USER} first (dev sign-in, no Google); --as <email> picks the user, --signed-out
|
|
216
|
+
shows the app signed out.
|
|
217
|
+
Then look at both PNGs (agents: open them with your file/image reader).
|
|
218
|
+
Exit 1 if the page logs a console error, throws an uncaught error, or a request fails; the PNGs are
|
|
219
|
+
still saved. Uses the running dev server, or starts one and stops it afterwards. Needs Google
|
|
220
|
+
Chrome installed; no browser is downloaded.`,
|
|
221
|
+
run: screenshot,
|
|
222
|
+
takesArgument: true,
|
|
223
|
+
flags: ['--commands', '--name', '--as', '--signed-out']
|
|
224
|
+
},
|
|
225
|
+
test: {
|
|
226
|
+
summary: 'Run every flow in flows/ headlessly (no browser); --visual runs them in Chrome with screenshots',
|
|
227
|
+
details: `Flows drive the real route table and app store with commands, then check the app's
|
|
228
|
+
snapshot (page, path, model and store state). They run in Node with no browser, so the whole suite takes about
|
|
229
|
+
a second. Write one per feature in flows/<Name>.flow.ts using flow(name, steps). A
|
|
230
|
+
{ screenshot: '<name>' } step is skipped here; only its name is checked.
|
|
231
|
+
|
|
232
|
+
--visual runs the same flows in headless Google Chrome at the desktop and compact viewports: each
|
|
233
|
+
command runs in the real app and each expect checks its snapshot. Every screenshot step saves
|
|
234
|
+
${pathFromCwd(FLOW_SCREENSHOTS_DIR)}/<name>-desktop.png and <name>-compact.png, and all of them are laid out
|
|
235
|
+
with labels in one contact sheet, ${pathFromCwd(CONTACT_SHEET)}, to review the whole app in one look.
|
|
236
|
+
Console errors, uncaught errors and failed requests fail the run. Uses the running dev server, or
|
|
237
|
+
starts one and stops it afterwards. Not part of app check.`,
|
|
238
|
+
run: test,
|
|
239
|
+
flags: ['--visual']
|
|
240
|
+
},
|
|
241
|
+
'sign-in': {
|
|
242
|
+
summary: 'Sign the app open in your browser in as a dev user (no Google); creates the user if new',
|
|
243
|
+
details: `app sign-in
|
|
244
|
+
app sign-in grace@acme.test
|
|
245
|
+
Runs the signIn.signInAsDevUser command in the open app, which calls /api/auth/dev-sign-in: the server finds or
|
|
246
|
+
creates that user in the local D1 database and sets the same session cookie Google sign-in sets, so
|
|
247
|
+
everything after it is the real signed-in app. Defaults to ${DEV_USER}. The route only exists in the
|
|
248
|
+
dev server (app check proves the production build has none). Sign out with app send '{"type":"app.signOut"}'.`,
|
|
249
|
+
run: (argument) => send(JSON.stringify({
|
|
250
|
+
type: 'signIn.signInAsDevUser',
|
|
251
|
+
email: argument ?? DEV_USER
|
|
252
|
+
})),
|
|
253
|
+
takesArgument: true
|
|
254
|
+
},
|
|
255
|
+
setup: {
|
|
256
|
+
summary: 'Verify Node/pnpm match the pins, pnpm install, install the global `app` shim',
|
|
257
|
+
details: `Idempotent. Fails if Node != .nvmrc or pnpm != package.json "packageManager".
|
|
258
|
+
Then runs \`pnpm install\` and installs ${SHIM_PATH}, a tiny shim that walks up from the
|
|
259
|
+
current directory to the nearest project launcher (an executable ./app containing the line
|
|
260
|
+
${LAUNCHER_MARKER_LINE}) and runs it, so each project's CLI lives in its repo and the shim
|
|
261
|
+
never needs updating. Replaces older Jig or starter-kit shims; refuses to overwrite an
|
|
262
|
+
existing ${SHIM_PATH} that was not installed by Pattern.`,
|
|
263
|
+
run: setup
|
|
264
|
+
},
|
|
265
|
+
help: {
|
|
266
|
+
summary: 'Show this help (`app <command> --help` for details)',
|
|
267
|
+
details: 'Lists commands. Flags: --json (machine-readable stdout), --help, --no-open (start only), --visual (test only), --commands, --name, --as and --signed-out (screenshot only).',
|
|
268
|
+
run: help
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
let jsonMode = false;
|
|
272
|
+
let openBrowser = false;
|
|
273
|
+
function progressStream() {
|
|
274
|
+
return jsonMode ? process.stderr : process.stdout;
|
|
275
|
+
}
|
|
276
|
+
function say(line) {
|
|
277
|
+
progressStream().write(`${line}\n`);
|
|
278
|
+
}
|
|
279
|
+
function pathFromCwd(path) {
|
|
280
|
+
const rel = relative(process.cwd(), path);
|
|
281
|
+
if (!relative(ROOT, process.cwd()).startsWith('..'))
|
|
282
|
+
return rel;
|
|
283
|
+
const home = homedir();
|
|
284
|
+
return path.startsWith(`${home}/`) ? `~/${relative(home, path)}` : rel;
|
|
285
|
+
}
|
|
286
|
+
function indent(text) {
|
|
287
|
+
return text
|
|
288
|
+
.split('\n')
|
|
289
|
+
.map((line) => ` ${line}`)
|
|
290
|
+
.join('\n');
|
|
291
|
+
}
|
|
292
|
+
function capture(bin, args, env = process.env) {
|
|
293
|
+
return new Promise((done) => {
|
|
294
|
+
let output = '';
|
|
295
|
+
let stdout = '';
|
|
296
|
+
const child = spawn(bin, args, {
|
|
297
|
+
cwd: ROOT,
|
|
298
|
+
env,
|
|
299
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
300
|
+
});
|
|
301
|
+
child.stdout.on('data', (chunk) => {
|
|
302
|
+
output += chunk.toString();
|
|
303
|
+
stdout += chunk.toString();
|
|
304
|
+
});
|
|
305
|
+
child.stderr.on('data', (chunk) => (output += chunk.toString()));
|
|
306
|
+
child.on('error', (err) => done({
|
|
307
|
+
code: 127,
|
|
308
|
+
output: `failed to run ${bin}: ${err.message}`,
|
|
309
|
+
stdout: ''
|
|
310
|
+
}));
|
|
311
|
+
child.on('close', (code) => done({ code: code ?? 1, output, stdout }));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function errCode(err) {
|
|
315
|
+
if (err instanceof Error && 'code' in err && typeof err.code === 'string') {
|
|
316
|
+
return err.code;
|
|
317
|
+
}
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
function sleep(ms) {
|
|
321
|
+
return new Promise((done) => setTimeout(done, ms));
|
|
322
|
+
}
|
|
323
|
+
function letPendingSpawnErrorEventFire() {
|
|
324
|
+
return sleep(0);
|
|
325
|
+
}
|
|
326
|
+
function readText(path) {
|
|
327
|
+
try {
|
|
328
|
+
return readFileSync(path, 'utf8');
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return '';
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function tail(text, lines) {
|
|
335
|
+
return text.trimEnd().split('\n').slice(-lines).join('\n');
|
|
336
|
+
}
|
|
337
|
+
function isServerState(value) {
|
|
338
|
+
if (typeof value !== 'object' || value === null)
|
|
339
|
+
return false;
|
|
340
|
+
const v = value;
|
|
341
|
+
return (typeof v['pid'] === 'number' &&
|
|
342
|
+
(typeof v['url'] === 'string' || v['url'] === null) &&
|
|
343
|
+
typeof v['startedAt'] === 'string');
|
|
344
|
+
}
|
|
345
|
+
const UNREADABLE_STATE_TREATED_AS_STALE = {
|
|
346
|
+
pid: -1,
|
|
347
|
+
url: null,
|
|
348
|
+
startedAt: ''
|
|
349
|
+
};
|
|
350
|
+
function readState() {
|
|
351
|
+
const raw = readText(STATE_FILE);
|
|
352
|
+
if (raw === '')
|
|
353
|
+
return undefined;
|
|
354
|
+
try {
|
|
355
|
+
const parsed = JSON.parse(raw);
|
|
356
|
+
return isServerState(parsed) ? parsed : UNREADABLE_STATE_TREATED_AS_STALE;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return UNREADABLE_STATE_TREATED_AS_STALE;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function writeState(state) {
|
|
363
|
+
mkdirSync(DEV_DIR, { recursive: true });
|
|
364
|
+
writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`);
|
|
365
|
+
}
|
|
366
|
+
function clearState() {
|
|
367
|
+
rmSync(STATE_FILE, { force: true });
|
|
368
|
+
}
|
|
369
|
+
function isLiveViteProcessNotAReusedPid(pid) {
|
|
370
|
+
if (pid <= 0)
|
|
371
|
+
return false;
|
|
372
|
+
try {
|
|
373
|
+
process.kill(pid, 0);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (errCode(err) !== 'EPERM')
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
const ps = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
380
|
+
encoding: 'utf8'
|
|
381
|
+
});
|
|
382
|
+
return ps.status === 0 && ps.stdout.includes('vite');
|
|
383
|
+
}
|
|
384
|
+
function groupAlive(pgid) {
|
|
385
|
+
try {
|
|
386
|
+
process.kill(-pgid, 0);
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
return errCode(err) === 'EPERM';
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function killGroup(pgid) {
|
|
394
|
+
try {
|
|
395
|
+
process.kill(-pgid, 'SIGTERM');
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
if (errCode(err) === 'ESRCH')
|
|
399
|
+
return;
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
403
|
+
while (Date.now() < deadline) {
|
|
404
|
+
if (!groupAlive(pgid))
|
|
405
|
+
return;
|
|
406
|
+
await sleep(50);
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
process.kill(-pgid, 'SIGKILL');
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
if (errCode(err) !== 'ESRCH')
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function clearIfStale(state) {
|
|
417
|
+
if (state === undefined || isLiveViteProcessNotAReusedPid(state.pid))
|
|
418
|
+
return undefined;
|
|
419
|
+
clearState();
|
|
420
|
+
return state.pid;
|
|
421
|
+
}
|
|
422
|
+
function uptimeSeconds(state) {
|
|
423
|
+
return Math.max(0, Math.round((Date.now() - Date.parse(state.startedAt)) / 1000));
|
|
424
|
+
}
|
|
425
|
+
function formatUptime(seconds) {
|
|
426
|
+
if (seconds < 60)
|
|
427
|
+
return `${seconds}s`;
|
|
428
|
+
if (seconds < 3600)
|
|
429
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
430
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
431
|
+
}
|
|
432
|
+
function runningOutcome(state, headline) {
|
|
433
|
+
const up = uptimeSeconds(state);
|
|
434
|
+
const url = state.url ?? '(url not yet known)';
|
|
435
|
+
return {
|
|
436
|
+
code: 0,
|
|
437
|
+
data: {
|
|
438
|
+
status: 'running',
|
|
439
|
+
url: state.url,
|
|
440
|
+
pid: state.pid,
|
|
441
|
+
uptimeSeconds: up,
|
|
442
|
+
log: LOG_FILE
|
|
443
|
+
},
|
|
444
|
+
text: [
|
|
445
|
+
`✓ ${headline} ${url}`,
|
|
446
|
+
` up ${formatUptime(up)}`,
|
|
447
|
+
` log ${pathFromCwd(LOG_FILE)}`,
|
|
448
|
+
` stop app stop`
|
|
449
|
+
].join('\n')
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function status() {
|
|
453
|
+
const state = readState();
|
|
454
|
+
if (state !== undefined && isLiveViteProcessNotAReusedPid(state.pid)) {
|
|
455
|
+
return runningOutcome(state, 'Dev server running at');
|
|
456
|
+
}
|
|
457
|
+
const stalePid = clearIfStale(state);
|
|
458
|
+
return {
|
|
459
|
+
code: 1,
|
|
460
|
+
data: {
|
|
461
|
+
status: 'stopped',
|
|
462
|
+
log: LOG_FILE,
|
|
463
|
+
removedStalePid: stalePid ?? null
|
|
464
|
+
},
|
|
465
|
+
text: [
|
|
466
|
+
'Dev server is not running',
|
|
467
|
+
` start app start`,
|
|
468
|
+
` log ${pathFromCwd(LOG_FILE)} (last run)`
|
|
469
|
+
].join('\n')
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
async function fetchBodyOrUndefined(url) {
|
|
473
|
+
try {
|
|
474
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
475
|
+
return await res.text();
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async function servesThisProjectsPackageJsonViaViteFs(base) {
|
|
482
|
+
const body = await fetchBodyOrUndefined(`${base}@fs${ROOT}/package.json`);
|
|
483
|
+
return body !== undefined && body === readText(join(ROOT, 'package.json'));
|
|
484
|
+
}
|
|
485
|
+
const LOOPBACK_HOSTS_STRICT_PORT_CANNOT_GUARD_ON_MACOS = ['127.0.0.1', '[::1]'];
|
|
486
|
+
async function foreignListenersOnDevPort(url) {
|
|
487
|
+
const { port } = new URL(url);
|
|
488
|
+
const foreign = [];
|
|
489
|
+
for (const host of LOOPBACK_HOSTS_STRICT_PORT_CANNOT_GUARD_ON_MACOS) {
|
|
490
|
+
const base = `http://${host}:${port}/`;
|
|
491
|
+
if ((await fetchBodyOrUndefined(base)) !== undefined &&
|
|
492
|
+
!(await servesThisProjectsPackageJsonViaViteFs(base))) {
|
|
493
|
+
foreign.push(base);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return foreign;
|
|
497
|
+
}
|
|
498
|
+
async function start() {
|
|
499
|
+
const existing = readState();
|
|
500
|
+
if (existing !== undefined && isLiveViteProcessNotAReusedPid(existing.pid)) {
|
|
501
|
+
return runningOutcome(existing, 'Dev server already running at');
|
|
502
|
+
}
|
|
503
|
+
clearIfStale(existing);
|
|
504
|
+
const viteBin = installedTool('vite');
|
|
505
|
+
if (viteBin === undefined) {
|
|
506
|
+
return {
|
|
507
|
+
code: 1,
|
|
508
|
+
data: { error: 'dependencies not installed' },
|
|
509
|
+
text: '✗ Dev server failed to start: dependencies are not installed\n fix app setup'
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
mkdirSync(DEV_DIR, { recursive: true });
|
|
513
|
+
const logFd = openSync(LOG_FILE, 'w');
|
|
514
|
+
const child = spawn(process.execPath, [viteBin], {
|
|
515
|
+
cwd: ROOT,
|
|
516
|
+
detached: true,
|
|
517
|
+
stdio: ['ignore', logFd, logFd],
|
|
518
|
+
env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }
|
|
519
|
+
});
|
|
520
|
+
closeSync(logFd);
|
|
521
|
+
child.unref();
|
|
522
|
+
let exitReason;
|
|
523
|
+
child.on('error', (err) => {
|
|
524
|
+
exitReason = `spawn error: ${err.message}`;
|
|
525
|
+
});
|
|
526
|
+
child.on('exit', (code, signal) => {
|
|
527
|
+
exitReason = `vite exited (${signal ?? `code ${code}`}) before responding`;
|
|
528
|
+
});
|
|
529
|
+
const pid = child.pid;
|
|
530
|
+
if (pid === undefined) {
|
|
531
|
+
await letPendingSpawnErrorEventFire();
|
|
532
|
+
return startFailure(exitReason ?? 'spawn failed');
|
|
533
|
+
}
|
|
534
|
+
const state = {
|
|
535
|
+
pid,
|
|
536
|
+
url: null,
|
|
537
|
+
startedAt: new Date().toISOString()
|
|
538
|
+
};
|
|
539
|
+
writeState(state);
|
|
540
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
541
|
+
while (exitReason === undefined && Date.now() < deadline) {
|
|
542
|
+
if (state.url === null) {
|
|
543
|
+
const match = /Local:\s+(https?:\/\/\S+)/.exec(readText(LOG_FILE));
|
|
544
|
+
if (match?.[1] !== undefined) {
|
|
545
|
+
state.url = match[1];
|
|
546
|
+
writeState(state);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (state.url !== null &&
|
|
550
|
+
(await servesThisProjectsPackageJsonViaViteFs(state.url))) {
|
|
551
|
+
const foreign = await foreignListenersOnDevPort(state.url);
|
|
552
|
+
if (foreign.length === 0) {
|
|
553
|
+
return runningOutcome(state, 'Dev server running at');
|
|
554
|
+
}
|
|
555
|
+
await killGroup(pid);
|
|
556
|
+
return startFailure(`port ${new URL(state.url).port} is already in use by another process (answering at ${foreign.join(', ')}). Stop it or change server.port in vite.config.ts.`);
|
|
557
|
+
}
|
|
558
|
+
await sleep(100);
|
|
559
|
+
}
|
|
560
|
+
if (exitReason === undefined) {
|
|
561
|
+
exitReason = `no response within ${START_TIMEOUT_MS / 1000}s`;
|
|
562
|
+
await killGroup(pid);
|
|
563
|
+
}
|
|
564
|
+
return startFailure(exitReason);
|
|
565
|
+
}
|
|
566
|
+
const SEEDED_FILE = join(DEV_DIR, 'seeded.json');
|
|
567
|
+
async function importExport(file, name) {
|
|
568
|
+
if (!existsSync(join(ROOT, file)))
|
|
569
|
+
return undefined;
|
|
570
|
+
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(join(ROOT, file)).href));
|
|
571
|
+
return typeof module === 'object' && module !== null
|
|
572
|
+
? Reflect.get(module, name)
|
|
573
|
+
: undefined;
|
|
574
|
+
}
|
|
575
|
+
async function projectData() {
|
|
576
|
+
const tables = await importExport('schema.ts', 'tables');
|
|
577
|
+
if (typeof tables !== 'object' || tables === null) {
|
|
578
|
+
throw new Error('schema.ts must export `tables`');
|
|
579
|
+
}
|
|
580
|
+
const seed = (await importExport('seed.ts', 'seed')) ?? {};
|
|
581
|
+
if (typeof seed !== 'object' || seed === null) {
|
|
582
|
+
throw new Error('seed.ts must export `seed`');
|
|
583
|
+
}
|
|
584
|
+
return { tables: tables, seed };
|
|
585
|
+
}
|
|
586
|
+
function fingerprint(seed) {
|
|
587
|
+
return createHash('sha256').update(JSON.stringify(seed)).digest('hex');
|
|
588
|
+
}
|
|
589
|
+
function recordSeed(seed) {
|
|
590
|
+
mkdirSync(DEV_DIR, { recursive: true });
|
|
591
|
+
writeFileSync(SEEDED_FILE, JSON.stringify({ seed: fingerprint(seed) }));
|
|
592
|
+
}
|
|
593
|
+
async function seedChangedNotice() {
|
|
594
|
+
const { seed } = await projectData();
|
|
595
|
+
if (!existsSync(SEEDED_FILE)) {
|
|
596
|
+
recordSeed(seed);
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
const recorded = JSON.parse(readText(SEEDED_FILE));
|
|
600
|
+
const previous = typeof recorded === 'object' && recorded !== null
|
|
601
|
+
? Reflect.get(recorded, 'seed')
|
|
602
|
+
: undefined;
|
|
603
|
+
return previous === fingerprint(seed)
|
|
604
|
+
? undefined
|
|
605
|
+
: ' seed seed.ts changed since the local database was filled; app reset fills it again';
|
|
606
|
+
}
|
|
607
|
+
async function reset() {
|
|
608
|
+
try {
|
|
609
|
+
const { tables, seed } = await projectData();
|
|
610
|
+
resetLocalDatabase(ROOT, tables, seed);
|
|
611
|
+
recordSeed(seed);
|
|
612
|
+
const rows = Object.values(seed).reduce((total, list) => total + (list?.length ?? 0), 0);
|
|
613
|
+
return {
|
|
614
|
+
code: 0,
|
|
615
|
+
data: { rows },
|
|
616
|
+
text: `✓ Local database emptied and filled with the ${rows} rows in seed.ts\n next reload the page`
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
621
|
+
return {
|
|
622
|
+
code: 1,
|
|
623
|
+
data: { error: message },
|
|
624
|
+
text: `✗ Could not reset the local database: ${message}`
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async function startAndOpen() {
|
|
629
|
+
const started = await start();
|
|
630
|
+
const notice = started.code === 0 ? await seedChangedNotice() : undefined;
|
|
631
|
+
const outcome = notice === undefined
|
|
632
|
+
? started
|
|
633
|
+
: { ...started, text: `${started.text}\n${notice}` };
|
|
634
|
+
const url = outcome.data['url'];
|
|
635
|
+
if (openBrowser && outcome.code === 0 && typeof url === 'string') {
|
|
636
|
+
openUrl(url);
|
|
637
|
+
return { ...outcome, text: `${outcome.text}\n Opened in your browser` };
|
|
638
|
+
}
|
|
639
|
+
return outcome;
|
|
640
|
+
}
|
|
641
|
+
function openUrl(url) {
|
|
642
|
+
const [bin, args] = process.platform === 'darwin'
|
|
643
|
+
? ['open', [url]]
|
|
644
|
+
: process.platform === 'win32'
|
|
645
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
646
|
+
: ['xdg-open', [url]];
|
|
647
|
+
const child = spawn(bin, args, { detached: true, stdio: 'ignore' });
|
|
648
|
+
child.on('error', () => undefined);
|
|
649
|
+
child.unref();
|
|
650
|
+
}
|
|
651
|
+
function startFailure(reason) {
|
|
652
|
+
clearState();
|
|
653
|
+
const logTail = tail(readText(LOG_FILE), LOG_TAIL_LINES);
|
|
654
|
+
return {
|
|
655
|
+
code: 1,
|
|
656
|
+
data: { error: reason, log: LOG_FILE, logTail },
|
|
657
|
+
text: [
|
|
658
|
+
`✗ Dev server failed to start: ${reason}`,
|
|
659
|
+
` log ${pathFromCwd(LOG_FILE)} (last ${LOG_TAIL_LINES} lines below)`,
|
|
660
|
+
'',
|
|
661
|
+
indent(logTail)
|
|
662
|
+
].join('\n')
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async function stop() {
|
|
666
|
+
const state = readState();
|
|
667
|
+
if (state === undefined) {
|
|
668
|
+
return {
|
|
669
|
+
code: 0,
|
|
670
|
+
data: { status: 'stopped', wasRunning: false },
|
|
671
|
+
text: 'Dev server was not running'
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
if (!isLiveViteProcessNotAReusedPid(state.pid)) {
|
|
675
|
+
const stalePid = clearIfStale(state);
|
|
676
|
+
return {
|
|
677
|
+
code: 0,
|
|
678
|
+
data: {
|
|
679
|
+
status: 'stopped',
|
|
680
|
+
wasRunning: false,
|
|
681
|
+
removedStalePid: stalePid ?? null
|
|
682
|
+
},
|
|
683
|
+
text: 'Dev server was not running (cleaned up a leftover pid file)'
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
await killGroup(state.pid);
|
|
687
|
+
clearState();
|
|
688
|
+
return {
|
|
689
|
+
code: 0,
|
|
690
|
+
data: { status: 'stopped', wasRunning: true, pid: state.pid },
|
|
691
|
+
text: '✓ Dev server stopped'
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
function installedTool(tool) {
|
|
695
|
+
try {
|
|
696
|
+
return toolBin(tool);
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
return undefined;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function runTool(args) {
|
|
703
|
+
return capture(process.execPath, args);
|
|
704
|
+
}
|
|
705
|
+
function toolFailure(error) {
|
|
706
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
707
|
+
return {
|
|
708
|
+
code: 1,
|
|
709
|
+
data: { error: message },
|
|
710
|
+
text: `✗ ${message}\n fix app setup`
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
async function send(argument) {
|
|
714
|
+
const state = readState();
|
|
715
|
+
if (argument === undefined) {
|
|
716
|
+
return {
|
|
717
|
+
code: 2,
|
|
718
|
+
data: { error: 'missing command' },
|
|
719
|
+
text: '✗ Missing command\n usage app send \'{"type":"app.navigate","to":"/settings"}\''
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
if (state === undefined ||
|
|
723
|
+
state.url === null ||
|
|
724
|
+
!isLiveViteProcessNotAReusedPid(state.pid)) {
|
|
725
|
+
return {
|
|
726
|
+
code: 1,
|
|
727
|
+
data: { error: 'dev server not running' },
|
|
728
|
+
text: '✗ The dev server is not running\n fix app start'
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
const response = await fetch(`${state.url}__pattern/send`, {
|
|
733
|
+
method: 'POST',
|
|
734
|
+
body: argument
|
|
735
|
+
});
|
|
736
|
+
const reply = await response.json();
|
|
737
|
+
const snapshot = typeof reply === 'object' && reply !== null && 'snapshot' in reply
|
|
738
|
+
? reply.snapshot
|
|
739
|
+
: undefined;
|
|
740
|
+
if (!response.ok || snapshot === undefined) {
|
|
741
|
+
const error = typeof reply === 'object' && reply !== null && 'error' in reply
|
|
742
|
+
? String(reply.error)
|
|
743
|
+
: `HTTP ${response.status}`;
|
|
744
|
+
return { code: 1, data: { error }, text: `✗ ${error}` };
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
code: 0,
|
|
748
|
+
data: { snapshot },
|
|
749
|
+
text: `✓ Sent ${argument}\n${indent(JSON.stringify(snapshot, null, 2))}`
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
return {
|
|
754
|
+
code: 1,
|
|
755
|
+
data: { error: String(error) },
|
|
756
|
+
text: `✗ Could not reach the dev server: ${String(error)}`
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
async function withDevServer(run) {
|
|
761
|
+
const existing = readState();
|
|
762
|
+
const wasRunning = existing !== undefined && isLiveViteProcessNotAReusedPid(existing.pid);
|
|
763
|
+
if (!wasRunning)
|
|
764
|
+
say('Starting the dev server for this run…');
|
|
765
|
+
const started = await start();
|
|
766
|
+
const url = started.data['url'];
|
|
767
|
+
if (started.code !== 0)
|
|
768
|
+
return started;
|
|
769
|
+
if (typeof url !== 'string') {
|
|
770
|
+
return {
|
|
771
|
+
code: 1,
|
|
772
|
+
data: { error: 'dev server URL not known yet' },
|
|
773
|
+
text: '✗ The dev server is still starting; rerun in a moment'
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
return await run(url);
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
if (!wasRunning) {
|
|
781
|
+
await stop();
|
|
782
|
+
say('Stopped the dev server this run started');
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function screenshotNameFromPath(path) {
|
|
787
|
+
const slug = path
|
|
788
|
+
.toLowerCase()
|
|
789
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
790
|
+
.replace(/^-+|-+$/g, '');
|
|
791
|
+
return slug === '' ? 'index' : slug;
|
|
792
|
+
}
|
|
793
|
+
function isScreenshotResult(value) {
|
|
794
|
+
return (typeof value === 'object' &&
|
|
795
|
+
value !== null &&
|
|
796
|
+
'screenshots' in value &&
|
|
797
|
+
Array.isArray(value.screenshots) &&
|
|
798
|
+
'problems' in value &&
|
|
799
|
+
Array.isArray(value.problems));
|
|
800
|
+
}
|
|
801
|
+
function workerError(value) {
|
|
802
|
+
return typeof value === 'object' &&
|
|
803
|
+
value !== null &&
|
|
804
|
+
'error' in value &&
|
|
805
|
+
typeof value.error === 'string'
|
|
806
|
+
? value.error
|
|
807
|
+
: undefined;
|
|
808
|
+
}
|
|
809
|
+
function parseLastJsonLine(text) {
|
|
810
|
+
const line = text.trimEnd().split('\n').at(-1) ?? '';
|
|
811
|
+
try {
|
|
812
|
+
return JSON.parse(line);
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
async function screenshot(argument, options) {
|
|
819
|
+
const usage = ' usage app screenshot /settings [--commands \'[{"type":"app.toggleSidebar"}]\'] [--name settings]';
|
|
820
|
+
if (argument === undefined || !argument.startsWith('/')) {
|
|
821
|
+
return {
|
|
822
|
+
code: 2,
|
|
823
|
+
data: { error: 'missing path' },
|
|
824
|
+
text: `✗ Give the path to screenshot, starting with /\n${usage}`
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
const rawCommands = options.get('--commands');
|
|
828
|
+
let commands = [];
|
|
829
|
+
if (typeof rawCommands === 'string') {
|
|
830
|
+
try {
|
|
831
|
+
commands = JSON.parse(rawCommands);
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
commands = undefined;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (!Array.isArray(commands)) {
|
|
838
|
+
return {
|
|
839
|
+
code: 2,
|
|
840
|
+
data: { error: '--commands must be a JSON array of commands' },
|
|
841
|
+
text: `✗ --commands must be a JSON array of commands (the ones app send takes)\n${usage}`
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const asOption = options.get('--as');
|
|
845
|
+
const signedInAs = options.has('--signed-out')
|
|
846
|
+
? null
|
|
847
|
+
: typeof asOption === 'string'
|
|
848
|
+
? asOption
|
|
849
|
+
: DEV_USER;
|
|
850
|
+
const nameOption = options.get('--name');
|
|
851
|
+
const name = typeof nameOption === 'string'
|
|
852
|
+
? nameOption
|
|
853
|
+
: screenshotNameFromPath(argument);
|
|
854
|
+
const path = argument;
|
|
855
|
+
return withDevServer(async (url) => {
|
|
856
|
+
const { output, stdout } = await capture('node', [
|
|
857
|
+
SCREENSHOT_WORKER,
|
|
858
|
+
JSON.stringify({ url, path, name, commands, signedInAs })
|
|
859
|
+
]);
|
|
860
|
+
const result = parseLastJsonLine(stdout);
|
|
861
|
+
const error = workerError(result);
|
|
862
|
+
if (!isScreenshotResult(result)) {
|
|
863
|
+
return {
|
|
864
|
+
code: 1,
|
|
865
|
+
data: { error: error ?? output.trimEnd() },
|
|
866
|
+
text: `✗ Screenshot of ${path} failed: ${error ?? 'the screenshot worker crashed'}${error === undefined ? `\n\n${indent(output.trimEnd())}` : ''}`
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
const files = result.screenshots.map((shot) => ` ${shot.viewport.padEnd(8)} ${`${shot.width}×${shot.height}`.padEnd(9)} ${pathFromCwd(shot.file)}`);
|
|
870
|
+
const ok = result.problems.length === 0;
|
|
871
|
+
const problems = result.problems.map((problem) => ` ✗ ${problem.message} (${problem.viewports.join(', ')})`);
|
|
872
|
+
return {
|
|
873
|
+
code: ok ? 0 : 1,
|
|
874
|
+
data: { path, name, ...result },
|
|
875
|
+
text: ok
|
|
876
|
+
? [`✓ Screenshots of ${path}`, ...files].join('\n')
|
|
877
|
+
: [
|
|
878
|
+
`✗ Screenshots of ${path} found ${result.problems.length} problem${result.problems.length === 1 ? '' : 's'}`,
|
|
879
|
+
...problems,
|
|
880
|
+
'',
|
|
881
|
+
' Screenshots were still saved:',
|
|
882
|
+
...files
|
|
883
|
+
].join('\n')
|
|
884
|
+
};
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
async function runFlows(env) {
|
|
888
|
+
const { code, output } = await capture(process.execPath, [toolBin('vitest'), 'run', '--reporter=verbose'], env);
|
|
889
|
+
const results = output
|
|
890
|
+
.split('\n')
|
|
891
|
+
.map((line) => line.trim())
|
|
892
|
+
.filter((line) => /^[✓×✗]/.test(line))
|
|
893
|
+
.map((line) => line.replace(/\s+\d+ms$/, ''));
|
|
894
|
+
return { code, output, results };
|
|
895
|
+
}
|
|
896
|
+
function flowScreenshots() {
|
|
897
|
+
try {
|
|
898
|
+
return readdirSync(FLOW_SCREENSHOTS_DIR)
|
|
899
|
+
.filter((entry) => entry.endsWith('.png'))
|
|
900
|
+
.sort()
|
|
901
|
+
.map((entry) => join(FLOW_SCREENSHOTS_DIR, entry));
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
return [];
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async function visualTest(url) {
|
|
908
|
+
const { code, output, results } = await runFlows({
|
|
909
|
+
...process.env,
|
|
910
|
+
[VISUAL_URL_ENV]: url
|
|
911
|
+
});
|
|
912
|
+
const screenshots = flowScreenshots();
|
|
913
|
+
const contactSheet = existsSync(CONTACT_SHEET) ? CONTACT_SHEET : null;
|
|
914
|
+
const saved = [
|
|
915
|
+
` screenshots ${pathFromCwd(FLOW_SCREENSHOTS_DIR)}/ (${screenshots.length} PNGs)`,
|
|
916
|
+
...(contactSheet === null
|
|
917
|
+
? []
|
|
918
|
+
: [` contact sheet ${pathFromCwd(contactSheet)}`])
|
|
919
|
+
];
|
|
920
|
+
const data = { results, screenshots, contactSheet };
|
|
921
|
+
if (code === 0) {
|
|
922
|
+
return {
|
|
923
|
+
code,
|
|
924
|
+
data,
|
|
925
|
+
text: [
|
|
926
|
+
...results,
|
|
927
|
+
'',
|
|
928
|
+
`✓ ${results.length} flow runs passed in Chrome`,
|
|
929
|
+
...saved,
|
|
930
|
+
' Look at the contact sheet (agents: open it with your image reader).'
|
|
931
|
+
].join('\n')
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
code,
|
|
936
|
+
data: { ...data, output: output.trimEnd() },
|
|
937
|
+
text: [
|
|
938
|
+
indent(output.trimEnd()),
|
|
939
|
+
'',
|
|
940
|
+
'✗ Visual flows failed',
|
|
941
|
+
...saved
|
|
942
|
+
].join('\n')
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
async function test(_argument, options) {
|
|
946
|
+
if (options.has('--visual'))
|
|
947
|
+
return withDevServer(visualTest);
|
|
948
|
+
const { code, output, results } = await runFlows(process.env);
|
|
949
|
+
if (code === 0) {
|
|
950
|
+
return {
|
|
951
|
+
code,
|
|
952
|
+
data: { results },
|
|
953
|
+
text: [...results, '', `✓ ${results.length} flows passed`].join('\n')
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
code,
|
|
958
|
+
data: { results, output: output.trimEnd() },
|
|
959
|
+
text: [indent(output.trimEnd()), '', '✗ Flows failed'].join('\n')
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
async function check(only) {
|
|
963
|
+
const fixes = {
|
|
964
|
+
format: 'app fix',
|
|
965
|
+
lint: 'app fix (auto-fixes StyleX key order and other fixable rules)'
|
|
966
|
+
};
|
|
967
|
+
const steps = only === undefined
|
|
968
|
+
? CHECK_STEPS
|
|
969
|
+
: CHECK_STEPS.filter((step) => step.name === only);
|
|
970
|
+
if (steps.length === 0) {
|
|
971
|
+
return {
|
|
972
|
+
code: 2,
|
|
973
|
+
data: { error: `unknown step: ${only}` },
|
|
974
|
+
text: `✗ Unknown step ${only}. Steps: ${CHECK_STEPS.map((step) => step.name).join(', ')}`
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
const results = [];
|
|
978
|
+
const width = Math.max(...steps.map((s) => s.name.length));
|
|
979
|
+
for (const step of steps) {
|
|
980
|
+
let args;
|
|
981
|
+
try {
|
|
982
|
+
args = step.run();
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
return toolFailure(error);
|
|
986
|
+
}
|
|
987
|
+
const { code, output } = await runTool(args);
|
|
988
|
+
const command = `app check ${step.name}`;
|
|
989
|
+
const ok = code === 0;
|
|
990
|
+
results.push({
|
|
991
|
+
name: step.name,
|
|
992
|
+
command,
|
|
993
|
+
exitCode: code,
|
|
994
|
+
ok,
|
|
995
|
+
...(ok ? {} : { output: output.trimEnd() })
|
|
996
|
+
});
|
|
997
|
+
if (ok) {
|
|
998
|
+
say(`✓ ${step.name}`);
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
const fix = fixes[step.name];
|
|
1002
|
+
say([
|
|
1003
|
+
`✗ ${step.name.padEnd(width)} rerun: ${command}`,
|
|
1004
|
+
...(fix === undefined
|
|
1005
|
+
? []
|
|
1006
|
+
: [` ${''.padEnd(width)} fix: ${fix}`]),
|
|
1007
|
+
'',
|
|
1008
|
+
indent(output.trimEnd()),
|
|
1009
|
+
''
|
|
1010
|
+
].join('\n'));
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const failed = results.filter((r) => !r.ok).map((r) => r.name);
|
|
1014
|
+
return {
|
|
1015
|
+
code: failed.length === 0 ? 0 : 1,
|
|
1016
|
+
data: { steps: results, failed },
|
|
1017
|
+
text: failed.length === 0
|
|
1018
|
+
? only === undefined
|
|
1019
|
+
? '\n✓ Check passed'
|
|
1020
|
+
: ''
|
|
1021
|
+
: `\n✗ Check failed: ${failed.join(', ')}`
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
async function fix() {
|
|
1025
|
+
let commands;
|
|
1026
|
+
try {
|
|
1027
|
+
commands = [
|
|
1028
|
+
[toolBin('prettier'), '--write', ...FORMATTED_PATHS],
|
|
1029
|
+
[toolBin('oxlint'), '--config', LINT_CONFIG, '--fix']
|
|
1030
|
+
];
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
return toolFailure(error);
|
|
1034
|
+
}
|
|
1035
|
+
for (const command of commands)
|
|
1036
|
+
await runTool(command);
|
|
1037
|
+
return {
|
|
1038
|
+
code: 0,
|
|
1039
|
+
data: {},
|
|
1040
|
+
text: '✓ Formatted and applied lint fixes\n next app check'
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
async function writeDatabaseSchema() {
|
|
1044
|
+
const project = await import(__rewriteRelativeImportExtension(pathToFileURL(join(ROOT, 'schema.ts')).href));
|
|
1045
|
+
const tables = typeof project === 'object' && project !== null
|
|
1046
|
+
? Reflect.get(project, 'tables')
|
|
1047
|
+
: undefined;
|
|
1048
|
+
if (typeof tables !== 'object' || tables === null) {
|
|
1049
|
+
throw new Error('schema.ts must export `tables`');
|
|
1050
|
+
}
|
|
1051
|
+
const keys = Object.keys(tables);
|
|
1052
|
+
const notIdentifier = keys.find((key) => !/^[A-Za-z_$][\w$]*$/.test(key));
|
|
1053
|
+
if (notIdentifier !== undefined) {
|
|
1054
|
+
throw new Error(`"${notIdentifier}" in tables is not a plain name`);
|
|
1055
|
+
}
|
|
1056
|
+
const server = join(SDK_SOURCE, 'server');
|
|
1057
|
+
const file = join(DEV_DIR, 'database.ts');
|
|
1058
|
+
mkdirSync(DEV_DIR, { recursive: true });
|
|
1059
|
+
writeFileSync(file, [
|
|
1060
|
+
`import { sqlTable } from ${JSON.stringify(join(server, `tables${SDK_EXTENSION}`))}`,
|
|
1061
|
+
`import { sessions } from ${JSON.stringify(join(server, `sessions${SDK_EXTENSION}`))}`,
|
|
1062
|
+
`import { users } from ${JSON.stringify(join(server, `users${SDK_EXTENSION}`))}`,
|
|
1063
|
+
`import { tables } from ${JSON.stringify(join(ROOT, 'schema.ts'))}`,
|
|
1064
|
+
'',
|
|
1065
|
+
'export { sessions, users }',
|
|
1066
|
+
...keys.map((key) => `export const ${key} = sqlTable(tables[${JSON.stringify(key)}])`),
|
|
1067
|
+
''
|
|
1068
|
+
].join('\n'));
|
|
1069
|
+
return file;
|
|
1070
|
+
}
|
|
1071
|
+
async function migration(name) {
|
|
1072
|
+
if (name === undefined || !/^[a-z0-9_]+$/.test(name)) {
|
|
1073
|
+
return {
|
|
1074
|
+
code: 2,
|
|
1075
|
+
data: { error: 'missing name' },
|
|
1076
|
+
text: '✗ Name the migration in snake_case\n usage app migration add_due_dates'
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
let drizzle;
|
|
1080
|
+
try {
|
|
1081
|
+
drizzle = toolBin('drizzle-kit');
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
return toolFailure(error);
|
|
1085
|
+
}
|
|
1086
|
+
let schema;
|
|
1087
|
+
try {
|
|
1088
|
+
schema = await writeDatabaseSchema();
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1092
|
+
return {
|
|
1093
|
+
code: 1,
|
|
1094
|
+
data: { error: message },
|
|
1095
|
+
text: `✗ Could not read the tables in schema.ts: ${message}`
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
const { code, output } = await runTool([
|
|
1099
|
+
drizzle,
|
|
1100
|
+
'generate',
|
|
1101
|
+
'--dialect',
|
|
1102
|
+
'sqlite',
|
|
1103
|
+
'--schema',
|
|
1104
|
+
schema,
|
|
1105
|
+
'--out',
|
|
1106
|
+
MIGRATIONS_DIR,
|
|
1107
|
+
'--name',
|
|
1108
|
+
name
|
|
1109
|
+
]);
|
|
1110
|
+
const warning = code === 0 ? ownerBackfillWarning(name) : undefined;
|
|
1111
|
+
return {
|
|
1112
|
+
code,
|
|
1113
|
+
data: { output: output.trimEnd(), ...(warning && { warning }) },
|
|
1114
|
+
text: `${code === 0 ? '✓' : '✗'} app migration ${name}\n\n${indent(output.trimEnd())}${warning === undefined ? '' : `\n\n! ${warning}`}`
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
function ownerBackfillWarning(name) {
|
|
1118
|
+
const file = readdirSync(MIGRATIONS_DIR)
|
|
1119
|
+
.filter((entry) => entry.endsWith(`_${name}.sql`))
|
|
1120
|
+
.sort()
|
|
1121
|
+
.at(-1);
|
|
1122
|
+
if (file === undefined)
|
|
1123
|
+
return undefined;
|
|
1124
|
+
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
|
|
1125
|
+
const tables = [
|
|
1126
|
+
...sql.matchAll(/ALTER TABLE `(\w+)` ADD `owner_id`/g),
|
|
1127
|
+
...sql.matchAll(/CREATE TABLE `__new_(\w+)` \([^;]*`owner_id`/g)
|
|
1128
|
+
].map((match) => match[1]);
|
|
1129
|
+
if (tables.length === 0)
|
|
1130
|
+
return undefined;
|
|
1131
|
+
return `${file} adds owner_id to ${tables.join(', ')}, which may already have rows. Backfill owner_id in this migration before the NOT NULL constraint, or reset the table.`;
|
|
1132
|
+
}
|
|
1133
|
+
async function build() {
|
|
1134
|
+
let vite;
|
|
1135
|
+
try {
|
|
1136
|
+
vite = toolBin('vite');
|
|
1137
|
+
}
|
|
1138
|
+
catch (error) {
|
|
1139
|
+
return toolFailure(error);
|
|
1140
|
+
}
|
|
1141
|
+
const { code, output } = await runTool([vite, 'build']);
|
|
1142
|
+
return {
|
|
1143
|
+
code,
|
|
1144
|
+
data: { output: output.trimEnd() },
|
|
1145
|
+
text: code === 0
|
|
1146
|
+
? '✓ Built into dist/'
|
|
1147
|
+
: `✗ Build failed\n\n${indent(output.trimEnd())}`
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
function readPins() {
|
|
1151
|
+
const node = readText(join(ROOT, '.nvmrc')).trim().replace(/^v/, '');
|
|
1152
|
+
const pkg = JSON.parse(readText(join(ROOT, 'package.json')));
|
|
1153
|
+
const pm = typeof pkg === 'object' && pkg !== null && 'packageManager' in pkg
|
|
1154
|
+
? String(pkg.packageManager)
|
|
1155
|
+
: '';
|
|
1156
|
+
return { node, pnpm: pm.replace(/^pnpm@/, '').split('+')[0] ?? '' };
|
|
1157
|
+
}
|
|
1158
|
+
function isPatternShim(source) {
|
|
1159
|
+
const lines = source.split('\n');
|
|
1160
|
+
return (lines.includes(SHIM_MARKER_LINE) ||
|
|
1161
|
+
LEGACY_SHIM_MARKERS.some((marker) => source.includes(marker)));
|
|
1162
|
+
}
|
|
1163
|
+
function installShim() {
|
|
1164
|
+
const current = existsSync(SHIM_PATH) ? readText(SHIM_PATH) : undefined;
|
|
1165
|
+
if (current !== undefined && !isPatternShim(current)) {
|
|
1166
|
+
return { status: 'refused', path: SHIM_PATH };
|
|
1167
|
+
}
|
|
1168
|
+
if (current === SHIM_SOURCE) {
|
|
1169
|
+
chmodSync(SHIM_PATH, 0o755);
|
|
1170
|
+
return { status: 'unchanged', path: SHIM_PATH };
|
|
1171
|
+
}
|
|
1172
|
+
mkdirSync(SHIM_DIR, { recursive: true });
|
|
1173
|
+
writeFileSync(SHIM_PATH, SHIM_SOURCE);
|
|
1174
|
+
chmodSync(SHIM_PATH, 0o755);
|
|
1175
|
+
return {
|
|
1176
|
+
status: current === undefined ? 'installed' : 'updated',
|
|
1177
|
+
path: SHIM_PATH
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
async function setup() {
|
|
1181
|
+
const pins = readPins();
|
|
1182
|
+
const haveNode = process.versions.node;
|
|
1183
|
+
const pnpmVersion = spawnSync('pnpm', ['--version'], {
|
|
1184
|
+
cwd: ROOT,
|
|
1185
|
+
encoding: 'utf8'
|
|
1186
|
+
});
|
|
1187
|
+
const havePnpm = (pnpmVersion.stdout ?? '').trim();
|
|
1188
|
+
const problems = [];
|
|
1189
|
+
if (haveNode !== pins.node) {
|
|
1190
|
+
problems.push(`Node ${haveNode} does not match .nvmrc (${pins.node})\n fix nvm install && nvm use`);
|
|
1191
|
+
}
|
|
1192
|
+
if (havePnpm !== pins.pnpm) {
|
|
1193
|
+
problems.push(`pnpm ${havePnpm || '(not found)'} does not match package.json packageManager (${pins.pnpm})\n fix corepack enable pnpm`);
|
|
1194
|
+
}
|
|
1195
|
+
const versions = { node: haveNode, pnpm: havePnpm, expected: pins };
|
|
1196
|
+
if (problems.length > 0) {
|
|
1197
|
+
return {
|
|
1198
|
+
code: 1,
|
|
1199
|
+
data: { versions, problems },
|
|
1200
|
+
text: problems.map((p) => `✗ ${p}`).join('\n')
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
say(`✓ Node ${haveNode} and pnpm ${havePnpm} match the pins`);
|
|
1204
|
+
const install = await capture('pnpm', ['install']);
|
|
1205
|
+
if (install.code !== 0) {
|
|
1206
|
+
return {
|
|
1207
|
+
code: install.code,
|
|
1208
|
+
data: { versions, error: 'pnpm install failed', output: install.output },
|
|
1209
|
+
text: `✗ Installing dependencies failed rerun: pnpm install\n\n${indent(install.output.trimEnd())}`
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
say('✓ Dependencies installed');
|
|
1213
|
+
const shim = installShim();
|
|
1214
|
+
const onPath = (process.env['PATH'] ?? '').split(delimiter).includes(SHIM_DIR);
|
|
1215
|
+
const lines = [];
|
|
1216
|
+
if (shim.status === 'refused') {
|
|
1217
|
+
lines.push(`✗ Not installing the \`app\` command: ${pathFromCwd(SHIM_PATH)} already exists and isn't from Pattern`, ' fix use ./app instead, or remove that file and rerun ./app setup');
|
|
1218
|
+
}
|
|
1219
|
+
else {
|
|
1220
|
+
const verb = shim.status === 'unchanged' ? 'already installed' : shim.status;
|
|
1221
|
+
lines.push(`✓ \`app\` command ${verb} (${pathFromCwd(SHIM_PATH)})`);
|
|
1222
|
+
if (!onPath) {
|
|
1223
|
+
lines.push(` warning ${pathFromCwd(SHIM_DIR)} is not on your PATH; add it to use \`app\` without ./`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
code: shim.status === 'refused' ? 1 : 0,
|
|
1228
|
+
data: { versions, shim, shimDirOnPath: onPath },
|
|
1229
|
+
text: lines.join('\n')
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function help() {
|
|
1233
|
+
const width = Math.max(...Object.keys(COMMANDS).map((n) => n.length));
|
|
1234
|
+
const lines = Object.entries(COMMANDS).map(([name, cmd]) => `app ${name.padEnd(width)} ${cmd.summary}`);
|
|
1235
|
+
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)');
|
|
1236
|
+
return {
|
|
1237
|
+
code: 0,
|
|
1238
|
+
data: {
|
|
1239
|
+
commands: Object.entries(COMMANDS).map(([name, cmd]) => ({
|
|
1240
|
+
name,
|
|
1241
|
+
summary: cmd.summary
|
|
1242
|
+
}))
|
|
1243
|
+
},
|
|
1244
|
+
text: lines.join('\n')
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
function parseArguments(argv) {
|
|
1248
|
+
const options = new Map();
|
|
1249
|
+
const positional = [];
|
|
1250
|
+
const problems = [];
|
|
1251
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1252
|
+
const arg = argv[i] ?? '';
|
|
1253
|
+
if (!arg.startsWith('-')) {
|
|
1254
|
+
positional.push(arg);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
const equals = arg.indexOf('=');
|
|
1258
|
+
const flag = equals === -1 ? arg : arg.slice(0, equals);
|
|
1259
|
+
if (!VALUE_FLAGS.has(flag)) {
|
|
1260
|
+
options.set(arg, true);
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
const value = equals === -1 ? argv[(i += 1)] : arg.slice(equals + 1);
|
|
1264
|
+
if (value === undefined)
|
|
1265
|
+
problems.push(`${flag} needs a value`);
|
|
1266
|
+
else
|
|
1267
|
+
options.set(flag, value);
|
|
1268
|
+
}
|
|
1269
|
+
return { options, positional, problems };
|
|
1270
|
+
}
|
|
1271
|
+
async function main(argv) {
|
|
1272
|
+
const { options, positional, problems } = parseArguments(argv);
|
|
1273
|
+
jsonMode = options.has('--json');
|
|
1274
|
+
const wantsHelp = options.has('--help') || options.has('-h');
|
|
1275
|
+
openBrowser =
|
|
1276
|
+
process.stdout.isTTY &&
|
|
1277
|
+
!jsonMode &&
|
|
1278
|
+
!options.has('--no-open') &&
|
|
1279
|
+
!process.env['CI'];
|
|
1280
|
+
const name = positional[0] ?? 'help';
|
|
1281
|
+
const command = COMMANDS[name];
|
|
1282
|
+
const allowedFlags = [...GLOBAL_FLAGS, ...(command?.flags ?? [])];
|
|
1283
|
+
const unknownFlags = [...options.keys()].filter((f) => !allowedFlags.includes(f));
|
|
1284
|
+
let outcome;
|
|
1285
|
+
const allowedPositionals = command?.takesArgument === true ? 2 : 1;
|
|
1286
|
+
if (command === undefined ||
|
|
1287
|
+
positional.length > allowedPositionals ||
|
|
1288
|
+
unknownFlags.length > 0 ||
|
|
1289
|
+
problems.length > 0) {
|
|
1290
|
+
const problem = command === undefined
|
|
1291
|
+
? `unknown command: ${name}`
|
|
1292
|
+
: positional.length > allowedPositionals
|
|
1293
|
+
? `unexpected argument: ${positional[allowedPositionals]}`
|
|
1294
|
+
: unknownFlags.length > 0
|
|
1295
|
+
? `unknown flag for ${name}: ${unknownFlags.join(' ')}`
|
|
1296
|
+
: problems.join(', ');
|
|
1297
|
+
const usage = help();
|
|
1298
|
+
outcome = {
|
|
1299
|
+
code: 2,
|
|
1300
|
+
data: { error: problem, ...usage.data },
|
|
1301
|
+
text: `app: ${problem}\n${usage.text}`
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
else if (wantsHelp) {
|
|
1305
|
+
outcome = {
|
|
1306
|
+
code: 0,
|
|
1307
|
+
data: { name, summary: command.summary, details: command.details },
|
|
1308
|
+
text: `app ${name} — ${command.summary}\n\n${command.details}`
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
else {
|
|
1312
|
+
outcome = await command.run(positional[1], options);
|
|
1313
|
+
}
|
|
1314
|
+
if (jsonMode) {
|
|
1315
|
+
const payload = {
|
|
1316
|
+
command: name,
|
|
1317
|
+
ok: outcome.code === 0,
|
|
1318
|
+
exitCode: outcome.code,
|
|
1319
|
+
...outcome.data
|
|
1320
|
+
};
|
|
1321
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
;
|
|
1325
|
+
(outcome.code === 2 ? process.stderr : process.stdout).write(`${outcome.text}\n`);
|
|
1326
|
+
}
|
|
1327
|
+
return outcome.code;
|
|
1328
|
+
}
|
|
1329
|
+
process.exitCode = await main(process.argv.slice(2));
|