@ericminassian/plat 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.
Files changed (156) hide show
  1. package/dist/api-types.d.ts +7 -0
  2. package/dist/api-types.js +3 -0
  3. package/dist/api-types.js.map +1 -0
  4. package/dist/api.d.ts +123 -0
  5. package/dist/api.js +271 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +4 -0
  9. package/dist/bin.js.map +1 -0
  10. package/dist/build.d.ts +45 -0
  11. package/dist/build.js +188 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/commands/account.d.ts +2 -0
  14. package/dist/commands/account.js +45 -0
  15. package/dist/commands/account.js.map +1 -0
  16. package/dist/commands/apps.d.ts +2 -0
  17. package/dist/commands/apps.js +75 -0
  18. package/dist/commands/apps.js.map +1 -0
  19. package/dist/commands/common.d.ts +19 -0
  20. package/dist/commands/common.js +43 -0
  21. package/dist/commands/common.js.map +1 -0
  22. package/dist/commands/lifecycle.d.ts +3 -0
  23. package/dist/commands/lifecycle.js +105 -0
  24. package/dist/commands/lifecycle.js.map +1 -0
  25. package/dist/commands/manage.d.ts +2 -0
  26. package/dist/commands/manage.js +103 -0
  27. package/dist/commands/manage.js.map +1 -0
  28. package/dist/commands/observe.d.ts +8 -0
  29. package/dist/commands/observe.js +195 -0
  30. package/dist/commands/observe.js.map +1 -0
  31. package/dist/credentials.d.ts +13 -0
  32. package/dist/credentials.js +42 -0
  33. package/dist/credentials.js.map +1 -0
  34. package/dist/deploy.d.ts +23 -0
  35. package/dist/deploy.js +100 -0
  36. package/dist/deploy.js.map +1 -0
  37. package/dist/dev/crons.d.ts +16 -0
  38. package/dist/dev/crons.js +47 -0
  39. package/dist/dev/crons.js.map +1 -0
  40. package/dist/dev/entries.d.ts +6 -0
  41. package/dist/dev/entries.js +21 -0
  42. package/dist/dev/entries.js.map +1 -0
  43. package/dist/dev/index.d.ts +33 -0
  44. package/dist/dev/index.js +262 -0
  45. package/dist/dev/index.js.map +1 -0
  46. package/dist/dev/local-stage.d.ts +46 -0
  47. package/dist/dev/local-stage.js +116 -0
  48. package/dist/dev/local-stage.js.map +1 -0
  49. package/dist/dev/pretty.d.ts +16 -0
  50. package/dist/dev/pretty.js +91 -0
  51. package/dist/dev/pretty.js.map +1 -0
  52. package/dist/dev/proc.d.ts +7 -0
  53. package/dist/dev/proc.js +16 -0
  54. package/dist/dev/proc.js.map +1 -0
  55. package/dist/dev/router.d.ts +67 -0
  56. package/dist/dev/router.js +184 -0
  57. package/dist/dev/router.js.map +1 -0
  58. package/dist/dev/server-entry.d.ts +1 -0
  59. package/dist/dev/server-entry.js +28 -0
  60. package/dist/dev/server-entry.js.map +1 -0
  61. package/dist/dev/test.d.ts +10 -0
  62. package/dist/dev/test.js +62 -0
  63. package/dist/dev/test.js.map +1 -0
  64. package/dist/dev/worker-host.d.ts +1 -0
  65. package/dist/dev/worker-host.js +67 -0
  66. package/dist/dev/worker-host.js.map +1 -0
  67. package/dist/dev/worker.d.ts +57 -0
  68. package/dist/dev/worker.js +216 -0
  69. package/dist/dev/worker.js.map +1 -0
  70. package/dist/github.d.ts +39 -0
  71. package/dist/github.js +108 -0
  72. package/dist/github.js.map +1 -0
  73. package/dist/index.d.ts +14 -0
  74. package/dist/index.js +15 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/init.d.ts +23 -0
  77. package/dist/init.js +102 -0
  78. package/dist/init.js.map +1 -0
  79. package/dist/main.d.ts +3 -0
  80. package/dist/main.js +24 -0
  81. package/dist/main.js.map +1 -0
  82. package/dist/project.d.ts +22 -0
  83. package/dist/project.js +81 -0
  84. package/dist/project.js.map +1 -0
  85. package/dist/util/net.d.ts +4 -0
  86. package/dist/util/net.js +33 -0
  87. package/dist/util/net.js.map +1 -0
  88. package/dist/util/output.d.ts +42 -0
  89. package/dist/util/output.js +105 -0
  90. package/dist/util/output.js.map +1 -0
  91. package/dist/util/prompt.d.ts +3 -0
  92. package/dist/util/prompt.js +22 -0
  93. package/dist/util/prompt.js.map +1 -0
  94. package/dist/util/time.d.ts +11 -0
  95. package/dist/util/time.js +29 -0
  96. package/dist/util/time.js.map +1 -0
  97. package/dist/util/zip.d.ts +10 -0
  98. package/dist/util/zip.js +45 -0
  99. package/dist/util/zip.js.map +1 -0
  100. package/package.json +50 -3
  101. package/src/api-types.ts +8 -0
  102. package/src/api.ts +341 -0
  103. package/src/bin.ts +4 -0
  104. package/src/build.ts +255 -0
  105. package/src/commands/account.ts +62 -0
  106. package/src/commands/apps.ts +102 -0
  107. package/src/commands/common.ts +51 -0
  108. package/src/commands/lifecycle.ts +139 -0
  109. package/src/commands/manage.ts +169 -0
  110. package/src/commands/observe.ts +276 -0
  111. package/src/credentials.ts +50 -0
  112. package/src/deploy.ts +138 -0
  113. package/src/dev/crons.ts +70 -0
  114. package/src/dev/entries.ts +23 -0
  115. package/src/dev/index.ts +281 -0
  116. package/src/dev/local-stage.ts +176 -0
  117. package/src/dev/pretty.ts +90 -0
  118. package/src/dev/proc.ts +15 -0
  119. package/src/dev/router.ts +246 -0
  120. package/src/dev/server-entry.ts +21 -0
  121. package/src/dev/test.ts +68 -0
  122. package/src/dev/worker-host.ts +62 -0
  123. package/src/dev/worker.ts +332 -0
  124. package/src/github.ts +121 -0
  125. package/src/index.ts +20 -0
  126. package/src/init.ts +123 -0
  127. package/src/main.ts +28 -0
  128. package/src/project.ts +89 -0
  129. package/src/util/net.ts +33 -0
  130. package/src/util/output.ts +117 -0
  131. package/src/util/prompt.ts +20 -0
  132. package/src/util/time.ts +32 -0
  133. package/src/util/zip.ts +46 -0
  134. package/template/.github/workflows/platform.yml +24 -0
  135. package/template/AGENTS.md +134 -0
  136. package/template/CLAUDE.md +1 -0
  137. package/template/README.md +13 -0
  138. package/template/_gitignore +8 -0
  139. package/template/package.json +31 -0
  140. package/template/platform.json +18 -0
  141. package/template/src/server.ts +100 -0
  142. package/template/src/types.ts +21 -0
  143. package/template/src/worker.ts +32 -0
  144. package/template/test/server.test.ts +120 -0
  145. package/template/test/todos.integration.test.ts +30 -0
  146. package/template/tsconfig.json +18 -0
  147. package/template/vitest.config.ts +9 -0
  148. package/template/vitest.integration.config.ts +10 -0
  149. package/template/web/index.html +12 -0
  150. package/template/web/src/App.tsx +102 -0
  151. package/template/web/src/api.ts +11 -0
  152. package/template/web/src/main.tsx +10 -0
  153. package/template/web/src/styles.css +102 -0
  154. package/template/web/tsconfig.json +18 -0
  155. package/template/web/vite.config.ts +19 -0
  156. package/README.md +0 -3
package/src/project.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { ManifestError, parseManifest, type ResolvedManifest } from '@ericminassian/platform-core';
6
+ import { CliError, EXIT } from './util/output.ts';
7
+
8
+ export const MANIFEST_FILE = 'platform.json';
9
+
10
+ export interface Project {
11
+ /** Directory containing platform.json. */
12
+ root: string;
13
+ /** platform.json as written. */
14
+ raw: Record<string, unknown>;
15
+ manifest: ResolvedManifest;
16
+ }
17
+
18
+ /** Walk up from `cwd` to the nearest platform.json. */
19
+ export function findProjectRoot(cwd: string): string | null {
20
+ let dir = resolve(cwd);
21
+ for (;;) {
22
+ if (existsSync(join(dir, MANIFEST_FILE))) return dir;
23
+ const parent = dirname(dir);
24
+ if (parent === dir) return null;
25
+ dir = parent;
26
+ }
27
+ }
28
+
29
+ export function loadProject(cwd: string): Project {
30
+ const root = findProjectRoot(cwd);
31
+ if (!root) {
32
+ throw new CliError(`no ${MANIFEST_FILE} found in ${cwd} or its parents`, {
33
+ hint: 'run this inside an app, or create one with `plat init <dir> --name <app>`',
34
+ exitCode: EXIT.usage,
35
+ });
36
+ }
37
+ let raw: Record<string, unknown>;
38
+ try {
39
+ raw = JSON.parse(readFileSync(join(root, MANIFEST_FILE), 'utf8'));
40
+ } catch (err) {
41
+ throw new CliError(`${MANIFEST_FILE} is not valid JSON: ${(err as Error).message}`, {
42
+ exitCode: EXIT.usage,
43
+ });
44
+ }
45
+ try {
46
+ return { root, raw, manifest: parseManifest(raw) };
47
+ } catch (err) {
48
+ if (err instanceof ManifestError) throw new CliError(err.message, { exitCode: EXIT.usage });
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ /** App name from `--app`, else the nearest platform.json. */
54
+ export function resolveAppName(opt: string | undefined, cwd = process.cwd()): string {
55
+ if (opt) return opt;
56
+ const root = findProjectRoot(cwd);
57
+ if (!root) {
58
+ throw new CliError('no app given', {
59
+ hint: 'pass --app <name> or run inside an app directory',
60
+ exitCode: EXIT.usage,
61
+ });
62
+ }
63
+ return loadProject(root).manifest.name;
64
+ }
65
+
66
+ export function cliVersion(): string {
67
+ return (cliPackageJson().version as string | undefined) ?? '0.0.0';
68
+ }
69
+
70
+ export function cliPackageRoot(): string {
71
+ return fileURLToPath(new URL('..', import.meta.url));
72
+ }
73
+
74
+ function cliPackageJson(): Record<string, unknown> {
75
+ return JSON.parse(readFileSync(join(cliPackageRoot(), 'package.json'), 'utf8'));
76
+ }
77
+
78
+ /**
79
+ * Resolve a module the way the *app* would (its own node_modules first), so
80
+ * the server wrapper and app code share one copy of the SDK. Falls back to the
81
+ * CLI's own dependency.
82
+ */
83
+ export function resolveFromApp(appRoot: string, specifier: string): string {
84
+ try {
85
+ return createRequire(join(appRoot, 'package.json')).resolve(specifier);
86
+ } catch {
87
+ return createRequire(import.meta.url).resolve(specifier);
88
+ }
89
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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,134 @@
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 `platform.json`; use it
10
+ through `@ericminassian/platform`. No CDK, CloudFormation, Terraform or `@aws-sdk/*` imports.
11
+ 2. **Every name in `platform.json` must match code.** Each queue, event and cron needs a handler in
12
+ `src/worker.ts`, and every handler needs a `platform.json` entry.
13
+ 3. **Never commit secrets.** Use `plat secrets set NAME` for deployed stages and `.env.local` (git-ignored)
14
+ for `plat dev`. Declare each name under `secrets`.
15
+ 4. **Run `pnpm typecheck && pnpm test` before you push.** CI runs the same checks, plus `plat test` when
16
+ `*.integration.test.ts` files exist.
17
+
18
+ ## Layout
19
+
20
+ | Path | What |
21
+ | ---------------------------- | -------------------------------------------------------------------------------------- |
22
+ | `platform.json` | Everything the app needs (web, auth, db, queues, events, crons, storage, secrets, env) |
23
+ | `src/server.ts` | HTTP API (Hono). `export default app`. Routes under `/api/*` |
24
+ | `src/worker.ts` | Background handlers: `defineWorker({ queues, events, crons })` |
25
+ | `src/types.ts` | Types shared by server, worker and web |
26
+ | `web/` | React SPA (Vite), built into `web/dist`, served from the edge |
27
+ | `test/*.test.ts` | Unit tests with a mocked SDK (`pnpm test`) |
28
+ | `test/*.integration.test.ts` | Real SDK against the AWS Emulator (`plat test`) |
29
+
30
+ Requests for `/api/*` and `/__platform/*` go to the server; files go to `web/dist`. Any other path
31
+ serves `index.html`, which is how client-side routing works. Change the server prefixes with
32
+ `web.serverRoutes` (default `["/api/"]`).
33
+
34
+ ## SDK reference (`@ericminassian/platform`)
35
+
36
+ ```ts
37
+ import { app, auth, db, events, log, metrics, queue, secrets, storage } from '@ericminassian/platform';
38
+ import { defineWorker, invokeJob } from '@ericminassian/platform/worker';
39
+ ```
40
+
41
+ | Export | Example |
42
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------- |
43
+ | `app` | `app.name`, `app.stage` (`prod`, `pr-12`, `local`, `test`), `app.url`, `app.isLocal` |
44
+ | `db.collection` | `const todos = db.collection<Todo>('todos', { partition: 'userId', sort: 'id' })` |
45
+ | `.get/.put/.update/.delete` | `await todos.put(t, { ifNotExists: true })`, `await todos.update({ userId, id }, { done: true })` |
46
+ | `.query` | `await todos.query({ userId }, { beginsWith: '2026', limit: 20, reverse: true })` → `{ items, cursor }` |
47
+ | `queue(name).send` | `await queue<EmailJob>('emails').send({ to, subject, body }, { delaySeconds: 60 })` |
48
+ | `events.publish` | `await events.publish('todo.created', { id })` (anything can publish; subscribers are listed in `events`) |
49
+ | `storage(name)` | `await storage('uploads').put(key, bytes, { contentType })`, `.get`, `.getText`, `.list(prefix)`, `.delete` |
50
+ | | `await storage('uploads').uploadUrl(key, { contentType })`: browser-direct upload URL (HTTP PUT) |
51
+ | `secrets.get` | `await secrets.get('STRIPE_KEY')` (throws if unset), `secrets.getOptional(...)` |
52
+ | `auth.getUser` | `await auth.getUser(c.req.raw)` → `{ id, email, name, groups }` or `null` |
53
+ | `auth.requireUser` | `await auth.requireUser(c.req.raw)`: throws a 401 `Response` when signed out |
54
+ | `auth.loginUrl/logoutUrl` | `auth.loginUrl('/dashboard')` → `/__platform/auth/login?redirect=...` |
55
+ | `metrics` | `metrics.count('signups')`, `metrics.value('cart.total', 42.5, 'usd')`: free, shows up in the console |
56
+ | `log` | `log.info('charged', { orderId })`, `log.error('failed', err)`: structured, traced, searchable |
57
+ | `defineWorker` | `defineWorker({ queues: { emails: async (msg) => {} }, events: {...}, crons: {...} })` |
58
+ | `invokeJob` | `await invokeJob('reindex', { id })`: async call to an `invoke` handler on this stage's worker |
59
+
60
+ User ids (`user.id`) are stable and shared across all platform apps. Every request, job, database call,
61
+ queue send and `fetch` is traced automatically. Don't add your own tracing or metrics plumbing.
62
+
63
+ ## Recipes
64
+
65
+ - **New collection or index.** Declare it in code; no infra change is needed. `db: true` must be in
66
+ `platform.json`. For a secondary index:
67
+ `db.collection<User>('users', { partition: 'id', indexes: { byEmail: { slot: 1, partition: 'email' } } })`,
68
+ then `users.query({ email }, { index: 'byEmail' })`.
69
+ - Slots 1-3 are fixed GSIs. Never reuse a slot for a different key in the same collection.
70
+ - For expiry, set `ttl: 'expiresAt'` (epoch seconds).
71
+ - **Queue.**
72
+ 1. Add `"queues": { "thumbnails": { "batchSize": 5 } }` to `platform.json`.
73
+ 2. Add a `queues.thumbnails` handler in `src/worker.ts`.
74
+ 3. Send with `queue('thumbnails').send(...)`.
75
+
76
+ Throwing in the handler retries the message; after `maxReceiveCount` (default 5) it goes to the
77
+ dead-letter queue, which you can inspect and redrive from the console.
78
+ - **Event subscription.** Add the type to `"events"`, then add an `events['order.paid']` handler.
79
+ - **Cron.** Add `"crons": { "digest": "0 8 * * 1-5" }` (5-field cron, UTC; or
80
+ `{ "schedule": "...", "timezone": "America/Los_Angeles" }`, or `"rate(15 minutes)"`), then add a
81
+ `crons.digest` handler.
82
+ - **File store.** Add `"storage": ["avatars"]`, then use `storage('avatars')`. For browser uploads, return
83
+ `uploadUrl(...)` from an API route and `PUT` the file to it.
84
+ - **Secret.**
85
+ 1. Add `"secrets": ["STRIPE_KEY"]` to `platform.json`.
86
+ 2. Set it: `plat secrets set STRIPE_KEY --stage prod`, or `--shared` for all stages.
87
+ 3. Locally, put `STRIPE_KEY=...` in `.env.local`.
88
+ 4. Read it: `await secrets.get('STRIPE_KEY')`.
89
+ - **Protected route.** Call `const user = await auth.requireUser(c.req.raw)` first. To require sign-in
90
+ for everything, set `"auth": { "required": true }`.
91
+ - **Plain config.** Use `"env": { "FEATURE_X": "on" }`, read via `process.env.FEATURE_X`. Not for secrets.
92
+ - **Custom metric.** Use `metrics.count('name')` or `metrics.value('name', n)`. It appears in the console
93
+ and in `plat metrics`.
94
+
95
+ ## Local loop
96
+
97
+ ```sh
98
+ pnpm install
99
+ pnpm dev # whole app on the AWS Emulator: http://template-app.localhost:3000
100
+ pnpm test # unit tests (mocked SDK)
101
+ pnpm test:integration # plat test: real SDK against a throwaway emulator
102
+ plat dev run-cron cleanup # fire a cron in the running `plat dev`
103
+ ```
104
+
105
+ `plat dev` has these properties:
106
+
107
+ - It provisions real (emulated) DynamoDB, SQS, EventBridge, S3, Cognito and SSM.
108
+ - The worker runs as a real Lambda inside the emulator.
109
+ - The server restarts when you edit, and so does the worker.
110
+ - Sign-in uses a local page where any email works.
111
+ - State is in memory and is gone when it stops.
112
+ - It needs the `aws-emulator` binary (see the hint it prints).
113
+
114
+ ## Deploying
115
+
116
+ - **Pull request:** deploys a preview to `https://template-app--pr-<n>.ericminassian.com`. The URL is
117
+ commented on the PR, and the preview is destroyed when the PR closes.
118
+ - **Merge to `main`:** deploys `https://template-app.ericminassian.com`.
119
+ - Deploys take seconds. A failed deploy never goes live. Roll back with `plat rollback`.
120
+ - One-time per app: `plat apps create template-app --repo <owner>/<repo>` registers the repo with the
121
+ platform. Without it, CI deploys are rejected.
122
+
123
+ ## Debugging (add `--json` for machine output)
124
+
125
+ ```sh
126
+ plat logs --since 30m [--stage pr-12] [--filter "error"] [--follow]
127
+ plat errors # grouped errors with sample stack + trace id
128
+ plat traces <traceId> # one request across server → queue → worker
129
+ plat metrics --since 6h # requests, 5xx, p95, cold starts, per-route and per-job stats
130
+ plat data todos --partition userId=<id>
131
+ plat deployments / plat stages / plat rollback
132
+ ```
133
+
134
+ 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,8 @@
1
+ node_modules/
2
+ .plat/
3
+ dist/
4
+ web/dist/
5
+ coverage/
6
+ .env.local
7
+ *.log
8
+ .DS_Store
@@ -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,18 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/eric-minassian/platform/main/packages/core/platform.schema.json",
3
+ "name": "template-app",
4
+ "description": "A todo app on the platform",
5
+ "web": {
6
+ "server": "src/server.ts",
7
+ "static": "web/dist",
8
+ "build": "vite build --config web/vite.config.ts",
9
+ "dev": { "command": "vite --config web/vite.config.ts", "port": 5173 },
10
+ "spa": true
11
+ },
12
+ "auth": true,
13
+ "db": true,
14
+ "queues": { "emails": {} },
15
+ "events": ["todo.created"],
16
+ "crons": { "cleanup": "0 3 * * *" },
17
+ "storage": ["uploads"]
18
+ }