@ericminassian/plat 0.0.0-stage → 0.2.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/dist/api-types.d.ts +7 -0
- package/dist/api-types.js +3 -0
- package/dist/api-types.js.map +1 -0
- package/dist/api.d.ts +123 -0
- package/dist/api.js +271 -0
- package/dist/api.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/build.d.ts +48 -0
- package/dist/build.js +185 -0
- package/dist/build.js.map +1 -0
- package/dist/commands/account.d.ts +2 -0
- package/dist/commands/account.js +45 -0
- package/dist/commands/account.js.map +1 -0
- package/dist/commands/apps.d.ts +2 -0
- package/dist/commands/apps.js +75 -0
- package/dist/commands/apps.js.map +1 -0
- package/dist/commands/common.d.ts +19 -0
- package/dist/commands/common.js +43 -0
- package/dist/commands/common.js.map +1 -0
- package/dist/commands/lifecycle.d.ts +3 -0
- package/dist/commands/lifecycle.js +106 -0
- package/dist/commands/lifecycle.js.map +1 -0
- package/dist/commands/manage.d.ts +2 -0
- package/dist/commands/manage.js +103 -0
- package/dist/commands/manage.js.map +1 -0
- package/dist/commands/observe.d.ts +8 -0
- package/dist/commands/observe.js +195 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +42 -0
- package/dist/credentials.js.map +1 -0
- package/dist/deploy.d.ts +23 -0
- package/dist/deploy.js +100 -0
- package/dist/deploy.js.map +1 -0
- package/dist/dev/crons.d.ts +16 -0
- package/dist/dev/crons.js +47 -0
- package/dist/dev/crons.js.map +1 -0
- package/dist/dev/entries.d.ts +6 -0
- package/dist/dev/entries.js +21 -0
- package/dist/dev/entries.js.map +1 -0
- package/dist/dev/index.d.ts +33 -0
- package/dist/dev/index.js +262 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/dev/local-stage.d.ts +46 -0
- package/dist/dev/local-stage.js +116 -0
- package/dist/dev/local-stage.js.map +1 -0
- package/dist/dev/pretty.d.ts +16 -0
- package/dist/dev/pretty.js +91 -0
- package/dist/dev/pretty.js.map +1 -0
- package/dist/dev/proc.d.ts +7 -0
- package/dist/dev/proc.js +16 -0
- package/dist/dev/proc.js.map +1 -0
- package/dist/dev/router.d.ts +67 -0
- package/dist/dev/router.js +184 -0
- package/dist/dev/router.js.map +1 -0
- package/dist/dev/server-entry.d.ts +1 -0
- package/dist/dev/server-entry.js +28 -0
- package/dist/dev/server-entry.js.map +1 -0
- package/dist/dev/test.d.ts +10 -0
- package/dist/dev/test.js +62 -0
- package/dist/dev/test.js.map +1 -0
- package/dist/dev/worker-host.d.ts +1 -0
- package/dist/dev/worker-host.js +67 -0
- package/dist/dev/worker-host.js.map +1 -0
- package/dist/dev/worker.d.ts +57 -0
- package/dist/dev/worker.js +216 -0
- package/dist/dev/worker.js.map +1 -0
- package/dist/github.d.ts +39 -0
- package/dist/github.js +108 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +30 -0
- package/dist/init.js +114 -0
- package/dist/init.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +24 -0
- package/dist/main.js.map +1 -0
- package/dist/project.d.ts +21 -0
- package/dist/project.js +138 -0
- package/dist/project.js.map +1 -0
- package/dist/util/esbuild.d.ts +2 -0
- package/dist/util/esbuild.js +9 -0
- package/dist/util/esbuild.js.map +1 -0
- package/dist/util/net.d.ts +4 -0
- package/dist/util/net.js +33 -0
- package/dist/util/net.js.map +1 -0
- package/dist/util/output.d.ts +42 -0
- package/dist/util/output.js +105 -0
- package/dist/util/output.js.map +1 -0
- package/dist/util/prompt.d.ts +3 -0
- package/dist/util/prompt.js +22 -0
- package/dist/util/prompt.js.map +1 -0
- package/dist/util/time.d.ts +11 -0
- package/dist/util/time.js +29 -0
- package/dist/util/time.js.map +1 -0
- package/dist/util/zip.d.ts +10 -0
- package/dist/util/zip.js +45 -0
- package/dist/util/zip.js.map +1 -0
- package/package.json +50 -3
- package/src/api-types.ts +8 -0
- package/src/api.ts +341 -0
- package/src/bin.ts +4 -0
- package/src/build.ts +252 -0
- package/src/commands/account.ts +62 -0
- package/src/commands/apps.ts +102 -0
- package/src/commands/common.ts +51 -0
- package/src/commands/lifecycle.ts +140 -0
- package/src/commands/manage.ts +169 -0
- package/src/commands/observe.ts +276 -0
- package/src/credentials.ts +50 -0
- package/src/deploy.ts +138 -0
- package/src/dev/crons.ts +70 -0
- package/src/dev/entries.ts +23 -0
- package/src/dev/index.ts +281 -0
- package/src/dev/local-stage.ts +176 -0
- package/src/dev/pretty.ts +90 -0
- package/src/dev/proc.ts +15 -0
- package/src/dev/router.ts +246 -0
- package/src/dev/server-entry.ts +21 -0
- package/src/dev/test.ts +68 -0
- package/src/dev/worker-host.ts +62 -0
- package/src/dev/worker.ts +332 -0
- package/src/github.ts +121 -0
- package/src/index.ts +20 -0
- package/src/init.ts +136 -0
- package/src/main.ts +28 -0
- package/src/project.ts +143 -0
- package/src/util/esbuild.ts +10 -0
- package/src/util/net.ts +33 -0
- package/src/util/output.ts +117 -0
- package/src/util/prompt.ts +20 -0
- package/src/util/time.ts +32 -0
- package/src/util/zip.ts +46 -0
- package/template/.github/workflows/platform.yml +24 -0
- package/template/AGENTS.md +139 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +13 -0
- package/template/_gitignore +8 -0
- package/template/package.json +31 -0
- package/template/plat.config.ts +31 -0
- package/template/src/server.ts +100 -0
- package/template/src/types.ts +21 -0
- package/template/src/worker.ts +32 -0
- package/template/test/server.test.ts +120 -0
- package/template/test/todos.integration.test.ts +30 -0
- package/template/tsconfig.json +18 -0
- package/template/vitest.config.ts +9 -0
- package/template/vitest.integration.config.ts +10 -0
- package/template/web/index.html +12 -0
- package/template/web/src/App.tsx +102 -0
- package/template/web/src/api.ts +11 -0
- package/template/web/src/main.tsx +10 -0
- package/template/web/src/styles.css +102 -0
- package/template/web/tsconfig.json +18 -0
- package/template/web/vite.config.ts +19 -0
- package/README.md +0 -3
package/src/project.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
APP_CONFIG_FILE,
|
|
7
|
+
ManifestError,
|
|
8
|
+
parseManifest,
|
|
9
|
+
type ResolvedManifest,
|
|
10
|
+
} from '@ericminassian/platform-core';
|
|
11
|
+
import { build as esbuild } from 'esbuild';
|
|
12
|
+
import { buildFailureList } from './util/esbuild.ts';
|
|
13
|
+
import { CliError, EXIT } from './util/output.ts';
|
|
14
|
+
|
|
15
|
+
/** Apps configured with JSON before `plat.config.ts`; only detected to say how to move. */
|
|
16
|
+
const LEGACY_MANIFEST_FILE = 'platform.json';
|
|
17
|
+
|
|
18
|
+
export interface Project {
|
|
19
|
+
/** Directory containing plat.config.ts. */
|
|
20
|
+
root: string;
|
|
21
|
+
/** The config's default export as JSON: exactly what is validated, built and deployed. */
|
|
22
|
+
raw: Record<string, unknown>;
|
|
23
|
+
manifest: ResolvedManifest;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Walk up from `cwd` to the nearest directory containing `name`. */
|
|
27
|
+
function findUp(cwd: string, name: string): string | null {
|
|
28
|
+
let dir = resolve(cwd);
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (existsSync(join(dir, name))) return dir;
|
|
31
|
+
const parent = dirname(dir);
|
|
32
|
+
if (parent === dir) return null;
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Walk up from `cwd` to the nearest plat.config.ts. */
|
|
38
|
+
export function findProjectRoot(cwd: string): string | null {
|
|
39
|
+
return findUp(cwd, APP_CONFIG_FILE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadProject(cwd: string): Promise<Project> {
|
|
43
|
+
const root = findProjectRoot(cwd);
|
|
44
|
+
if (!root) {
|
|
45
|
+
const legacy = findUp(cwd, LEGACY_MANIFEST_FILE);
|
|
46
|
+
throw new CliError(`no ${APP_CONFIG_FILE} found in ${cwd} or its parents`, {
|
|
47
|
+
hint: legacy
|
|
48
|
+
? `${join(legacy, LEGACY_MANIFEST_FILE)} is no longer read: move its fields into ${APP_CONFIG_FILE} as \`export default defineApp({ ... })\` from '@ericminassian/platform/config'`
|
|
49
|
+
: 'run this inside an app, or create one with `plat init <dir> --name <app>`',
|
|
50
|
+
exitCode: EXIT.usage,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const exported = await importConfig(root);
|
|
54
|
+
if (exported === null || typeof exported !== 'object' || Array.isArray(exported)) {
|
|
55
|
+
throw new CliError(`${APP_CONFIG_FILE} must default-export the app config`, {
|
|
56
|
+
hint: "export default defineApp({ name: '...' }) from '@ericminassian/platform/config'",
|
|
57
|
+
exitCode: EXIT.usage,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// The platform receives JSON, so validate (and later deploy) the JSON form.
|
|
61
|
+
const raw = JSON.parse(JSON.stringify(exported)) as Record<string, unknown>;
|
|
62
|
+
try {
|
|
63
|
+
return { root, raw, manifest: parseManifest(raw) };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof ManifestError) throw new CliError(err.message, { exitCode: EXIT.usage });
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Bundles the config (its relative imports included, packages left external)
|
|
72
|
+
* into `.plat/` and imports that, like Vite does for vite.config.ts. This
|
|
73
|
+
* works whatever the app's package `type` is, and a fresh file name means a
|
|
74
|
+
* changed config is always re-read.
|
|
75
|
+
*/
|
|
76
|
+
async function importConfig(root: string): Promise<unknown> {
|
|
77
|
+
const outfile = join(root, '.plat', `config-${process.pid}-${Date.now()}.mjs`);
|
|
78
|
+
try {
|
|
79
|
+
await esbuild({
|
|
80
|
+
entryPoints: [join(root, APP_CONFIG_FILE)],
|
|
81
|
+
outfile,
|
|
82
|
+
bundle: true,
|
|
83
|
+
packages: 'external',
|
|
84
|
+
platform: 'node',
|
|
85
|
+
format: 'esm',
|
|
86
|
+
target: 'node24',
|
|
87
|
+
sourcemap: 'inline',
|
|
88
|
+
logLevel: 'silent',
|
|
89
|
+
absWorkingDir: root,
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw new CliError(`could not compile ${APP_CONFIG_FILE}:${buildFailureList(err)}`, {
|
|
93
|
+
exitCode: EXIT.usage,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const mod = (await import(pathToFileURL(outfile).href)) as { default?: unknown };
|
|
98
|
+
return mod.default;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
throw new CliError(`${APP_CONFIG_FILE} threw while loading: ${(err as Error).message}`, {
|
|
101
|
+
exitCode: EXIT.usage,
|
|
102
|
+
});
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(outfile, { force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** App name from `--app`, else the nearest plat.config.ts. */
|
|
109
|
+
export async function resolveAppName(opt: string | undefined, cwd = process.cwd()): Promise<string> {
|
|
110
|
+
if (opt) return opt;
|
|
111
|
+
if (!findProjectRoot(cwd)) {
|
|
112
|
+
throw new CliError('no app given', {
|
|
113
|
+
hint: 'pass --app <name> or run inside an app directory',
|
|
114
|
+
exitCode: EXIT.usage,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return (await loadProject(cwd)).manifest.name;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function cliVersion(): string {
|
|
121
|
+
return (cliPackageJson().version as string | undefined) ?? '0.0.0';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function cliPackageRoot(): string {
|
|
125
|
+
return fileURLToPath(new URL('..', import.meta.url));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function cliPackageJson(): Record<string, unknown> {
|
|
129
|
+
return JSON.parse(readFileSync(join(cliPackageRoot(), 'package.json'), 'utf8'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a module the way the *app* would (its own node_modules first), so
|
|
134
|
+
* the server wrapper and app code share one copy of the SDK. Falls back to the
|
|
135
|
+
* CLI's own dependency.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveFromApp(appRoot: string, specifier: string): string {
|
|
138
|
+
try {
|
|
139
|
+
return createRequire(join(appRoot, 'package.json')).resolve(specifier);
|
|
140
|
+
} catch {
|
|
141
|
+
return createRequire(import.meta.url).resolve(specifier);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** An esbuild failure as `file:line: message` items (or the error's message), for a CliError list. */
|
|
2
|
+
export function buildFailureList(err: unknown): string {
|
|
3
|
+
const errors = (
|
|
4
|
+
err as { errors?: Array<{ text: string; location?: { file: string; line: number } | null }> }
|
|
5
|
+
).errors;
|
|
6
|
+
const lines = errors?.length
|
|
7
|
+
? errors.map((e) => (e.location ? `${e.location.file}:${e.location.line}: ${e.text}` : e.text))
|
|
8
|
+
: [(err as Error).message];
|
|
9
|
+
return `\n - ${lines.join('\n - ')}`;
|
|
10
|
+
}
|
package/src/util/net.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
|
|
3
|
+
/** A free TCP port on 127.0.0.1. */
|
|
4
|
+
export function freePort(): Promise<number> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const srv = createServer();
|
|
7
|
+
srv.once('error', reject);
|
|
8
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
9
|
+
const addr = srv.address();
|
|
10
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
11
|
+
srv.close(() => resolve(port));
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Resolves once something accepts TCP connections on host:port. */
|
|
17
|
+
export async function waitForPort(port: number, host = '127.0.0.1', timeoutMs = 30_000): Promise<void> {
|
|
18
|
+
const { connect } = await import('node:net');
|
|
19
|
+
const deadline = Date.now() + timeoutMs;
|
|
20
|
+
while (Date.now() < deadline) {
|
|
21
|
+
const ok = await new Promise<boolean>((resolve) => {
|
|
22
|
+
const s = connect(port, host);
|
|
23
|
+
s.once('connect', () => {
|
|
24
|
+
s.end();
|
|
25
|
+
resolve(true);
|
|
26
|
+
});
|
|
27
|
+
s.once('error', () => resolve(false));
|
|
28
|
+
});
|
|
29
|
+
if (ok) return;
|
|
30
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`nothing listening on ${host}:${port} after ${timeoutMs}ms`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Output conventions: human-readable by default, `--json` prints exactly one
|
|
5
|
+
* JSON document on stdout (progress goes to stderr) so agents can parse it.
|
|
6
|
+
*/
|
|
7
|
+
export class CliError extends Error {
|
|
8
|
+
readonly hint?: string;
|
|
9
|
+
readonly exitCode: number;
|
|
10
|
+
constructor(message: string, opts: { hint?: string; exitCode?: number } = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.hint = opts.hint;
|
|
13
|
+
this.exitCode = opts.exitCode ?? 1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const EXIT = {
|
|
18
|
+
ok: 0,
|
|
19
|
+
error: 1,
|
|
20
|
+
usage: 2,
|
|
21
|
+
auth: 3,
|
|
22
|
+
notFound: 4,
|
|
23
|
+
deployFailed: 5,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export interface OutputOptions {
|
|
27
|
+
json?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let jsonMode = false;
|
|
31
|
+
|
|
32
|
+
export function setJsonMode(on: boolean): void {
|
|
33
|
+
jsonMode = on;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isJsonMode(): boolean {
|
|
37
|
+
return jsonMode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Progress/status lines; stderr so `--json` stdout stays clean. */
|
|
41
|
+
export function info(msg: string): void {
|
|
42
|
+
process.stderr.write(`${msg}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function step(msg: string): void {
|
|
46
|
+
info(`${pc.cyan('›')} ${msg}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function success(msg: string): void {
|
|
50
|
+
info(`${pc.green('✓')} ${msg}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function warn(msg: string): void {
|
|
54
|
+
info(`${pc.yellow('!')} ${msg}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Prints the command result: JSON in `--json` mode, else via `human`. */
|
|
58
|
+
export function result<T>(value: T, human: (v: T) => string | undefined): void {
|
|
59
|
+
if (jsonMode) {
|
|
60
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const text = human(value);
|
|
64
|
+
if (text) process.stdout.write(`${text}\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatError(err: unknown): { message: string; hint?: string; exitCode: number } {
|
|
68
|
+
if (err instanceof CliError) return { message: err.message, hint: err.hint, exitCode: err.exitCode };
|
|
69
|
+
if (err instanceof Error) return { message: err.message, exitCode: EXIT.error };
|
|
70
|
+
return { message: String(err), exitCode: EXIT.error };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function reportError(err: unknown): number {
|
|
74
|
+
const { message, hint, exitCode } = formatError(err);
|
|
75
|
+
if (jsonMode) {
|
|
76
|
+
process.stdout.write(`${JSON.stringify({ error: { message, hint, exitCode } }, null, 2)}\n`);
|
|
77
|
+
} else {
|
|
78
|
+
info(`${pc.red('✗')} ${message}`);
|
|
79
|
+
if (hint) info(` ${pc.dim(hint)}`);
|
|
80
|
+
}
|
|
81
|
+
return exitCode;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Plain-text table with padded columns. */
|
|
85
|
+
export function table(rows: Array<Record<string, unknown>>, columns?: string[]): string {
|
|
86
|
+
if (rows.length === 0) return pc.dim('(none)');
|
|
87
|
+
const cols = columns ?? Object.keys(rows[0]!);
|
|
88
|
+
const cell = (v: unknown) =>
|
|
89
|
+
v === undefined || v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
90
|
+
const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => visibleLength(cell(r[c])))));
|
|
91
|
+
const line = (vals: string[]) =>
|
|
92
|
+
vals.map((v, i) => v + ' '.repeat(widths[i]! - visibleLength(v))).join(' ');
|
|
93
|
+
return [pc.bold(line(cols)), ...rows.map((r) => line(cols.map((c) => cell(r[c]))))].join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes is the point
|
|
97
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
98
|
+
function visibleLength(s: string): number {
|
|
99
|
+
return s.replace(ANSI, '').length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const SPARKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
103
|
+
|
|
104
|
+
/** Unicode sparkline; nulls render as spaces. */
|
|
105
|
+
export function sparkline(values: Array<number | null>): string {
|
|
106
|
+
const nums = values.filter((v): v is number => v !== null && Number.isFinite(v));
|
|
107
|
+
if (nums.length === 0) return '';
|
|
108
|
+
const min = Math.min(...nums);
|
|
109
|
+
const max = Math.max(...nums);
|
|
110
|
+
return values
|
|
111
|
+
.map((v) => {
|
|
112
|
+
if (v === null || !Number.isFinite(v)) return ' ';
|
|
113
|
+
if (max === min) return v === 0 ? SPARKS[0] : SPARKS[3];
|
|
114
|
+
return SPARKS[Math.round(((v - min) / (max - min)) * (SPARKS.length - 1))];
|
|
115
|
+
})
|
|
116
|
+
.join('');
|
|
117
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { CliError, EXIT } from './output.ts';
|
|
3
|
+
|
|
4
|
+
export async function prompt(question: string): Promise<string> {
|
|
5
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
6
|
+
try {
|
|
7
|
+
return await rl.question(question);
|
|
8
|
+
} finally {
|
|
9
|
+
rl.close();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Ask the user to type `expected` back; `--yes` skips it, and non-interactive runs must pass it. */
|
|
14
|
+
export async function confirmByTyping(question: string, expected: string, yes?: boolean): Promise<void> {
|
|
15
|
+
if (yes) return;
|
|
16
|
+
if (!process.stdin.isTTY)
|
|
17
|
+
throw new CliError('confirmation needed', { hint: 'pass --yes', exitCode: EXIT.usage });
|
|
18
|
+
const answer = await prompt(`${question} type ${expected} to confirm: `);
|
|
19
|
+
if (answer.trim() !== expected) throw new CliError('aborted', { exitCode: EXIT.usage });
|
|
20
|
+
}
|
package/src/util/time.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Resolution } from '@ericminassian/platform-core';
|
|
2
|
+
import { CliError, EXIT } from './output.ts';
|
|
3
|
+
|
|
4
|
+
const UNITS: Record<string, number> = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
5
|
+
|
|
6
|
+
/** `"90s" | "15m" | "6h" | "7d" | "2w"` → milliseconds. */
|
|
7
|
+
export function parseDuration(input: string): number {
|
|
8
|
+
const m = /^(\d+(?:\.\d+)?)\s*([smhdw])$/.exec(input.trim());
|
|
9
|
+
if (!m)
|
|
10
|
+
throw new CliError(`invalid duration "${input}"`, { hint: 'use e.g. 30m, 6h, 7d', exitCode: EXIT.usage });
|
|
11
|
+
return Number(m[1]) * UNITS[m[2]!]!;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Pick a rollup resolution that yields a readable number of points. */
|
|
15
|
+
export function resolutionFor(rangeMs: number): Resolution {
|
|
16
|
+
if (rangeMs <= 6 * UNITS.h!) return '1m';
|
|
17
|
+
if (rangeMs <= 14 * UNITS.d!) return '1h';
|
|
18
|
+
return '1d';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sinceRange(since: string, now = Date.now()): { from: number; to: number; res: Resolution } {
|
|
22
|
+
const ms = parseDuration(since);
|
|
23
|
+
return { from: now - ms, to: now, res: resolutionFor(ms) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shortTime(iso: string | number): string {
|
|
27
|
+
const d = new Date(iso);
|
|
28
|
+
return d
|
|
29
|
+
.toISOString()
|
|
30
|
+
.replace('T', ' ')
|
|
31
|
+
.replace(/\.\d+Z$/, 'Z');
|
|
32
|
+
}
|
package/src/util/zip.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { unzipSync, type Zippable, zipSync } from 'fflate';
|
|
4
|
+
|
|
5
|
+
/** Every file under `dir`, as zip paths (forward slashes) → absolute paths. */
|
|
6
|
+
export function listFiles(dir: string): Array<{ zipPath: string; absPath: string }> {
|
|
7
|
+
const out: Array<{ zipPath: string; absPath: string }> = [];
|
|
8
|
+
const walk = (d: string) => {
|
|
9
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
10
|
+
const abs = join(d, entry.name);
|
|
11
|
+
if (entry.isDirectory()) walk(abs);
|
|
12
|
+
else if (entry.isFile()) out.push({ zipPath: relative(dir, abs).split(sep).join('/'), absPath: abs });
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
walk(dir);
|
|
16
|
+
return out.sort((a, b) => a.zipPath.localeCompare(b.zipPath));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Zip a directory. Timestamps are fixed so identical inputs produce identical zips. */
|
|
20
|
+
export function zipDirectory(dir: string): Uint8Array {
|
|
21
|
+
const files: Zippable = {};
|
|
22
|
+
for (const f of listFiles(dir)) files[f.zipPath] = [readFileSync(f.absPath), { mtime: FIXED_MTIME }];
|
|
23
|
+
return zipSync(files, { level: 6 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function zipEntries(entries: Record<string, Uint8Array | string>): Uint8Array {
|
|
27
|
+
const files: Zippable = {};
|
|
28
|
+
for (const [name, data] of Object.entries(entries)) {
|
|
29
|
+
files[name] = [typeof data === 'string' ? new TextEncoder().encode(data) : data, { mtime: FIXED_MTIME }];
|
|
30
|
+
}
|
|
31
|
+
return zipSync(files, { level: 6 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function zipFileNames(zip: Uint8Array): string[] {
|
|
35
|
+
return Object.keys(unzipSync(zip)).sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function dirExists(path: string): boolean {
|
|
39
|
+
try {
|
|
40
|
+
return statSync(path).isDirectory();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FIXED_MTIME = new Date('2020-01-01T00:00:00Z');
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Test, build and deploy on the platform. PRs get a preview at
|
|
2
|
+
# https://template-app--pr-<n>.ericminassian.com (removed when the PR closes);
|
|
3
|
+
# main deploys https://template-app.ericminassian.com. No AWS credentials:
|
|
4
|
+
# CI signs in to the platform with GitHub's OIDC token.
|
|
5
|
+
name: platform
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
branches: [main]
|
|
10
|
+
pull_request:
|
|
11
|
+
types: [opened, synchronize, reopened, closed]
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write
|
|
15
|
+
contents: read
|
|
16
|
+
pull-requests: write
|
|
17
|
+
|
|
18
|
+
concurrency:
|
|
19
|
+
group: platform-${{ github.event.pull_request.number || github.ref }}
|
|
20
|
+
cancel-in-progress: false
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
platform:
|
|
24
|
+
uses: eric-minassian/platform/.github/workflows/app.yml@main
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# template-app: agent guide
|
|
2
|
+
|
|
3
|
+
This app runs on **the platform** (github.com/eric-minassian/platform), a PaaS for small apps. You write
|
|
4
|
+
the app; the platform provisions, deploys, routes, signs users in and monitors it. Live at
|
|
5
|
+
`https://template-app.ericminassian.com`.
|
|
6
|
+
|
|
7
|
+
## Golden rules
|
|
8
|
+
|
|
9
|
+
1. **No infrastructure code and no AWS SDK in this repo.** Declare what you need in `plat.config.ts`; use
|
|
10
|
+
it through `@ericminassian/platform`. No CDK, CloudFormation, Terraform or `@aws-sdk/*` imports.
|
|
11
|
+
2. **`plat.config.ts` is the source of truth for names and payload types.** `pnpm typecheck` fails unless
|
|
12
|
+
`src/worker.ts` handles exactly its queues, events and crons, and every `queue()`, `events.publish()`,
|
|
13
|
+
`storage()` and `secrets.get()` uses a declared name with the declared payload type. Declare message
|
|
14
|
+
types there (`queue<EmailJob>()`, `event<TodoCreated>()`) rather than annotating handlers, and keep the
|
|
15
|
+
`Register` block at the end of the file.
|
|
16
|
+
3. **Never commit secrets.** Use `plat secrets set NAME` for deployed stages and `.env.local` (git-ignored)
|
|
17
|
+
for `plat dev`. Declare each name under `secrets`.
|
|
18
|
+
4. **Run `pnpm typecheck && pnpm test` before you push.** CI runs the same checks, plus `plat test` when
|
|
19
|
+
`*.integration.test.ts` files exist.
|
|
20
|
+
|
|
21
|
+
## Layout
|
|
22
|
+
|
|
23
|
+
| Path | What |
|
|
24
|
+
| ---------------------------- | -------------------------------------------------------------------------------------- |
|
|
25
|
+
| `plat.config.ts` | Everything the app needs (web, auth, db, queues, events, crons, storage, secrets, env) |
|
|
26
|
+
| `src/server.ts` | HTTP API (Hono). `export default app`. Routes under `/api/*` |
|
|
27
|
+
| `src/worker.ts` | Background handlers: `defineWorker({ queues, events, crons })` |
|
|
28
|
+
| `src/types.ts` | Types shared by server, worker and web |
|
|
29
|
+
| `web/` | React SPA (Vite), built into `web/dist`, served from the edge |
|
|
30
|
+
| `test/*.test.ts` | Unit tests with a mocked SDK (`pnpm test`) |
|
|
31
|
+
| `test/*.integration.test.ts` | Real SDK against the AWS Emulator (`plat test`) |
|
|
32
|
+
|
|
33
|
+
Requests for `/api/*` and `/__platform/*` go to the server; files go to `web/dist`. Any other path
|
|
34
|
+
serves `index.html`, which is how client-side routing works. Change the server prefixes with
|
|
35
|
+
`web.serverRoutes` (default `['/api/']`).
|
|
36
|
+
|
|
37
|
+
## SDK reference (`@ericminassian/platform`)
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { app, auth, db, events, log, metrics, queue, secrets, storage } from '@ericminassian/platform';
|
|
41
|
+
import { defineWorker, invokeJob } from '@ericminassian/platform/worker';
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Export | Example |
|
|
45
|
+
| --------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
46
|
+
| `app` | `app.name`, `app.stage` (`prod`, `pr-12`, `local`, `test`), `app.url`, `app.isLocal` |
|
|
47
|
+
| `db.collection` | `const todos = db.collection<Todo>('todos', { partition: 'userId', sort: 'id' })` |
|
|
48
|
+
| `.get/.put/.update/.delete` | `await todos.put(t, { ifNotExists: true })`, `await todos.update({ userId, id }, { done: true })` |
|
|
49
|
+
| `.query` | `await todos.query({ userId }, { beginsWith: '2026', limit: 20, reverse: true })` → `{ items, cursor }` |
|
|
50
|
+
| `queue(name).send` | `await queue('emails').send({ to, subject, body }, { delaySeconds: 60 })` |
|
|
51
|
+
| `events.publish` | `await events.publish('todo.created', { userId, id, title })` |
|
|
52
|
+
| `storage(name)` | `await storage('uploads').put(key, bytes, { contentType })`, `.get`, `.getText`, `.list(prefix)`, `.delete` |
|
|
53
|
+
| | `await storage('uploads').uploadUrl(key, { contentType })`: browser-direct upload URL (HTTP PUT) |
|
|
54
|
+
| `secrets.get` | `await secrets.get('STRIPE_KEY')` (throws if unset), `secrets.getOptional(...)` |
|
|
55
|
+
| `auth.getUser` | `await auth.getUser(c.req.raw)` → `{ id, email, name, groups }` or `null` |
|
|
56
|
+
| `auth.requireUser` | `await auth.requireUser(c.req.raw)`: throws a 401 `Response` when signed out |
|
|
57
|
+
| `auth.loginUrl/logoutUrl` | `auth.loginUrl('/dashboard')` → `/__platform/auth/login?redirect=...` |
|
|
58
|
+
| `metrics` | `metrics.count('signups')`, `metrics.value('cart.total', 42.5, 'usd')`: free, shows up in the console |
|
|
59
|
+
| `log` | `log.info('charged', { orderId })`, `log.error('failed', err)`: structured, traced, searchable |
|
|
60
|
+
| `defineWorker` | `defineWorker({ queues: { emails: async (msg) => {} }, events: {...}, crons: {...} })`; `msg.body` is typed |
|
|
61
|
+
| `invokeJob` | `await invokeJob('reindex', { id })`: async call to an `invoke` handler on this stage's worker |
|
|
62
|
+
|
|
63
|
+
User ids (`user.id`) are stable and shared across all platform apps. Every request, job, database call,
|
|
64
|
+
queue send and `fetch` is traced automatically. Don't add your own tracing or metrics plumbing.
|
|
65
|
+
|
|
66
|
+
## Recipes
|
|
67
|
+
|
|
68
|
+
- **New collection or index.** Declare it in code; no infra change is needed. `db: true` must be in
|
|
69
|
+
`plat.config.ts`. For a secondary index:
|
|
70
|
+
`db.collection<User>('users', { partition: 'id', indexes: { byEmail: { slot: 1, partition: 'email' } } })`,
|
|
71
|
+
then `users.query({ email }, { index: 'byEmail' })`.
|
|
72
|
+
- Slots 1-3 are fixed GSIs. Never reuse a slot for a different key in the same collection.
|
|
73
|
+
- For expiry, set `ttl: 'expiresAt'` (epoch seconds).
|
|
74
|
+
- **Queue.**
|
|
75
|
+
1. Add `thumbnails: queue<ThumbnailJob>({ batchSize: 5 })` under `queues` in `plat.config.ts`, with
|
|
76
|
+
`ThumbnailJob` in `src/types.ts`.
|
|
77
|
+
2. Add a `queues.thumbnails` handler in `src/worker.ts`.
|
|
78
|
+
3. Send with `queue('thumbnails').send(...)`.
|
|
79
|
+
|
|
80
|
+
Throwing in the handler retries the message; after `maxReceiveCount` (default 5) it goes to the
|
|
81
|
+
dead-letter queue, which you can inspect and redrive from the console.
|
|
82
|
+
- **Event subscription.** Add `'order.paid': event<OrderPaid>()` under `events`, then add an
|
|
83
|
+
`events['order.paid']` handler. Publish with `events.publish('order.paid', detail)`.
|
|
84
|
+
- **Cron.** Add `digest: '0 8 * * 1-5'` under `crons` (5-field cron, UTC; or
|
|
85
|
+
`{ schedule: '...', timezone: 'America/Los_Angeles' }`, or `'rate(15 minutes)'`), then add a
|
|
86
|
+
`crons.digest` handler.
|
|
87
|
+
- **File store.** Add `'avatars'` to `storage`, then use `storage('avatars')`. For browser uploads, return
|
|
88
|
+
`uploadUrl(...)` from an API route and `PUT` the file to it.
|
|
89
|
+
- **Secret.**
|
|
90
|
+
1. Add `'STRIPE_KEY'` to `secrets` in `plat.config.ts`.
|
|
91
|
+
2. Set it: `plat secrets set STRIPE_KEY --stage prod`, or `--shared` for all stages.
|
|
92
|
+
3. Locally, put `STRIPE_KEY=...` in `.env.local`.
|
|
93
|
+
4. Read it: `await secrets.get('STRIPE_KEY')`.
|
|
94
|
+
- **Protected route.** Call `const user = await auth.requireUser(c.req.raw)` first. To require sign-in
|
|
95
|
+
for everything, set `auth: { required: true }`.
|
|
96
|
+
- **Plain config.** Use `env: { FEATURE_X: 'on' }`, read via `process.env.FEATURE_X`. Not for secrets.
|
|
97
|
+
- **Custom metric.** Use `metrics.count('name')` or `metrics.value('name', n)`. It appears in the console
|
|
98
|
+
and in `plat metrics`.
|
|
99
|
+
|
|
100
|
+
## Local loop
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
pnpm install
|
|
104
|
+
pnpm dev # whole app on the AWS Emulator: http://template-app.localhost:3000
|
|
105
|
+
pnpm test # unit tests (mocked SDK)
|
|
106
|
+
pnpm test:integration # plat test: real SDK against a throwaway emulator
|
|
107
|
+
plat dev run-cron cleanup # fire a cron in the running `plat dev`
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`plat dev` has these properties:
|
|
111
|
+
|
|
112
|
+
- It provisions real (emulated) DynamoDB, SQS, EventBridge, S3, Cognito and SSM.
|
|
113
|
+
- The worker runs as a real Lambda inside the emulator.
|
|
114
|
+
- The server restarts when you edit, and so does the worker.
|
|
115
|
+
- Sign-in uses a local page where any email works.
|
|
116
|
+
- State is in memory and is gone when it stops.
|
|
117
|
+
- It needs the `aws-emulator` binary (see the hint it prints).
|
|
118
|
+
|
|
119
|
+
## Deploying
|
|
120
|
+
|
|
121
|
+
- **Pull request:** deploys a preview to `https://template-app--pr-<n>.ericminassian.com`. The URL is
|
|
122
|
+
commented on the PR, and the preview is destroyed when the PR closes.
|
|
123
|
+
- **Merge to `main`:** deploys `https://template-app.ericminassian.com`.
|
|
124
|
+
- Deploys take seconds. A failed deploy never goes live. Roll back with `plat rollback`.
|
|
125
|
+
- One-time per app: `plat apps create template-app --repo <owner>/<repo>` registers the repo with the
|
|
126
|
+
platform. Without it, CI deploys are rejected.
|
|
127
|
+
|
|
128
|
+
## Debugging (add `--json` for machine output)
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
plat logs --since 30m [--stage pr-12] [--filter "error"] [--follow]
|
|
132
|
+
plat errors # grouped errors with sample stack + trace id
|
|
133
|
+
plat traces <traceId> # one request across server → queue → worker
|
|
134
|
+
plat metrics --since 6h # requests, 5xx, p95, cold starts, per-route and per-job stats
|
|
135
|
+
plat data todos --partition userId=<id>
|
|
136
|
+
plat deployments / plat stages / plat rollback
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The console at https://platform.ericminassian.com shows the same data with graphs.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# template-app
|
|
2
|
+
|
|
3
|
+
A todo app on [the platform](https://github.com/eric-minassian/platform), with sign-in, a database, a
|
|
4
|
+
queue, an event, a cron and file storage. It lives at https://template-app.ericminassian.com.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pnpm install
|
|
8
|
+
pnpm dev # http://template-app.localhost:3000 (AWS Emulator; install `aws-emulator` once)
|
|
9
|
+
pnpm test
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Pull requests deploy previews and `main` deploys production (`.github/workflows/platform.yml`). For
|
|
13
|
+
everything else (the SDK, recipes, debugging) see [AGENTS.md](./AGENTS.md).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "platform-app-template",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "plat dev",
|
|
8
|
+
"build": "plat build",
|
|
9
|
+
"deploy": "plat deploy",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:integration": "plat test",
|
|
12
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p web/tsconfig.json"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@ericminassian/platform": "workspace:*",
|
|
16
|
+
"hono": "^4.13.18",
|
|
17
|
+
"zod": "^4.6.7"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@ericminassian/plat": "workspace:*",
|
|
21
|
+
"@types/node": "^24.19.2",
|
|
22
|
+
"@types/react": "^19.3.0",
|
|
23
|
+
"@types/react-dom": "^19.3.0",
|
|
24
|
+
"@vitejs/plugin-react": "^6.3.0",
|
|
25
|
+
"react": "^19.3.1",
|
|
26
|
+
"react-dom": "^19.3.1",
|
|
27
|
+
"typescript": "^7.0.2",
|
|
28
|
+
"vite": "^8.3.8",
|
|
29
|
+
"vitest": "^5.1.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineApp, event, queue } from '@ericminassian/platform/config';
|
|
2
|
+
import type { EmailJob, TodoCreated } from './src/types.ts';
|
|
3
|
+
|
|
4
|
+
// Everything this app needs from the platform. `plat` evaluates this file and
|
|
5
|
+
// provisions what it declares; the SDK checks names and payload types in code
|
|
6
|
+
// against it (see `Register` below).
|
|
7
|
+
const app = defineApp({
|
|
8
|
+
name: 'template-app',
|
|
9
|
+
description: 'A todo app on the platform',
|
|
10
|
+
web: {
|
|
11
|
+
server: 'src/server.ts',
|
|
12
|
+
static: 'web/dist',
|
|
13
|
+
build: 'vite build --config web/vite.config.ts',
|
|
14
|
+
dev: { command: 'vite --config web/vite.config.ts', port: 5173 },
|
|
15
|
+
spa: true,
|
|
16
|
+
},
|
|
17
|
+
auth: true,
|
|
18
|
+
db: true,
|
|
19
|
+
queues: { emails: queue<EmailJob>() },
|
|
20
|
+
events: { 'todo.created': event<TodoCreated>() },
|
|
21
|
+
crons: { cleanup: '0 3 * * *' },
|
|
22
|
+
storage: ['uploads'],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export default app;
|
|
26
|
+
|
|
27
|
+
declare module '@ericminassian/platform' {
|
|
28
|
+
interface Register {
|
|
29
|
+
app: typeof app;
|
|
30
|
+
}
|
|
31
|
+
}
|