@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/api.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed client for the control plane API (`/v1`). Route shapes follow the
|
|
3
|
+
* design in docs/DESIGN.md; list endpoints return `{ <plural>: [...] }`.
|
|
4
|
+
*
|
|
5
|
+
* Auth, in order: an explicit token, GitHub Actions OIDC (CI), the token
|
|
6
|
+
* stored by `plat login`.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
type AppRecord,
|
|
10
|
+
type AppSummary,
|
|
11
|
+
type CliLoginRequest,
|
|
12
|
+
type CliLoginResponse,
|
|
13
|
+
type CreateAppRequest,
|
|
14
|
+
type CreateDeploymentRequest,
|
|
15
|
+
type CreateDeploymentResponse,
|
|
16
|
+
type DataPage,
|
|
17
|
+
type DeploymentRecord,
|
|
18
|
+
type ErrorGroup,
|
|
19
|
+
type JobStats,
|
|
20
|
+
type LogLine,
|
|
21
|
+
type LogsResponse,
|
|
22
|
+
type MeResponse,
|
|
23
|
+
type MetricsResponse,
|
|
24
|
+
PLATFORM_DEFAULTS,
|
|
25
|
+
type QueueStats,
|
|
26
|
+
type Resolution,
|
|
27
|
+
type RouteStats,
|
|
28
|
+
type SecretInfo,
|
|
29
|
+
type StageRecord,
|
|
30
|
+
type StoredObjectInfo,
|
|
31
|
+
type TraceResponse,
|
|
32
|
+
} from './api-types.ts';
|
|
33
|
+
import { isExpired, loadCredentials } from './credentials.ts';
|
|
34
|
+
import { canUseOidc, fetchOidcToken } from './github.ts';
|
|
35
|
+
import { CliError, EXIT } from './util/output.ts';
|
|
36
|
+
|
|
37
|
+
export function apiBaseUrl(env = process.env): string {
|
|
38
|
+
return (env.PLATFORM_API_URL || PLATFORM_DEFAULTS.apiUrl).replace(/\/$/, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ApiError extends CliError {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
readonly code: string;
|
|
44
|
+
constructor(status: number, code: string, message: string) {
|
|
45
|
+
super(message, {
|
|
46
|
+
hint: hintFor(status),
|
|
47
|
+
exitCode: status === 401 || status === 403 ? EXIT.auth : status === 404 ? EXIT.notFound : EXIT.error,
|
|
48
|
+
});
|
|
49
|
+
this.status = status;
|
|
50
|
+
this.code = code;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hintFor(status: number): string | undefined {
|
|
55
|
+
if (status === 401) return 'run `plat login` (or check the CI workflow has `id-token: write`)';
|
|
56
|
+
if (status === 403) return 'you are not allowed to do this; ask a platform admin';
|
|
57
|
+
if (status === 404) return 'check the app/stage name with `plat apps list` / `plat stages`';
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type AuthMode = 'auto' | 'token' | 'oidc' | 'none';
|
|
62
|
+
|
|
63
|
+
export interface ApiClientOptions {
|
|
64
|
+
baseUrl?: string;
|
|
65
|
+
token?: string;
|
|
66
|
+
auth?: AuthMode;
|
|
67
|
+
fetch?: typeof fetch;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Query = Record<string, string | number | boolean | undefined>;
|
|
71
|
+
|
|
72
|
+
export class ApiClient {
|
|
73
|
+
readonly baseUrl: string;
|
|
74
|
+
private readonly fetchImpl: typeof fetch;
|
|
75
|
+
private readonly authMode: AuthMode;
|
|
76
|
+
private token?: string;
|
|
77
|
+
|
|
78
|
+
constructor(opts: ApiClientOptions = {}) {
|
|
79
|
+
this.baseUrl = (opts.baseUrl ?? apiBaseUrl()).replace(/\/$/, '');
|
|
80
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
81
|
+
this.authMode = opts.auth ?? 'auto';
|
|
82
|
+
this.token = opts.token;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The bearer token this client will send (resolved lazily). */
|
|
86
|
+
async authorization(): Promise<string | undefined> {
|
|
87
|
+
if (this.token) return this.token;
|
|
88
|
+
if (this.authMode === 'none') return undefined;
|
|
89
|
+
if (this.authMode !== 'token' && canUseOidc()) {
|
|
90
|
+
this.token = await fetchOidcToken(PLATFORM_DEFAULTS.oidcAudience);
|
|
91
|
+
return this.token;
|
|
92
|
+
}
|
|
93
|
+
if (this.authMode === 'oidc') {
|
|
94
|
+
throw new CliError('GitHub Actions OIDC is not available here', { exitCode: EXIT.auth });
|
|
95
|
+
}
|
|
96
|
+
const creds = loadCredentials();
|
|
97
|
+
if (!creds) throw new CliError('not logged in', { hint: 'run `plat login`', exitCode: EXIT.auth });
|
|
98
|
+
if (isExpired(creds))
|
|
99
|
+
throw new CliError('your platform login has expired', {
|
|
100
|
+
hint: 'run `plat login`',
|
|
101
|
+
exitCode: EXIT.auth,
|
|
102
|
+
});
|
|
103
|
+
if (creds.apiUrl.replace(/\/$/, '') !== this.baseUrl) {
|
|
104
|
+
throw new CliError(`stored login is for ${creds.apiUrl}, not ${this.baseUrl}`, {
|
|
105
|
+
hint: 'run `plat login` again',
|
|
106
|
+
exitCode: EXIT.auth,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
this.token = creds.token;
|
|
110
|
+
return this.token;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
url(path: string, query?: Query): string {
|
|
114
|
+
const u = new URL(this.baseUrl + path);
|
|
115
|
+
for (const [k, v] of Object.entries(query ?? {}))
|
|
116
|
+
if (v !== undefined && v !== '') u.searchParams.set(k, String(v));
|
|
117
|
+
return u.toString();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async request<T>(method: string, path: string, opts: { query?: Query; body?: unknown } = {}): Promise<T> {
|
|
121
|
+
const headers: Record<string, string> = { accept: 'application/json' };
|
|
122
|
+
const auth = await this.authorization();
|
|
123
|
+
if (auth) headers.authorization = `Bearer ${auth}`;
|
|
124
|
+
if (opts.body !== undefined) headers['content-type'] = 'application/json';
|
|
125
|
+
let res: Response;
|
|
126
|
+
try {
|
|
127
|
+
res = await this.fetchImpl(this.url(path, opts.query), {
|
|
128
|
+
method,
|
|
129
|
+
headers,
|
|
130
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw new CliError(`can't reach the platform API at ${this.baseUrl}: ${(err as Error).message}`, {
|
|
134
|
+
hint: 'set PLATFORM_API_URL if the API lives elsewhere',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const text = await res.text();
|
|
138
|
+
const body = text ? safeJson(text) : undefined;
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
const e = (body as { error?: { code?: string; message?: string } } | undefined)?.error;
|
|
141
|
+
throw new ApiError(
|
|
142
|
+
res.status,
|
|
143
|
+
e?.code ?? `http_${res.status}`,
|
|
144
|
+
e?.message ?? `${method} ${path} failed (HTTP ${res.status})`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return body as T;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Server-sent events stream; yields parsed `data:` payloads. */
|
|
151
|
+
async *stream<T>(path: string, query?: Query, signal?: AbortSignal): AsyncGenerator<T> {
|
|
152
|
+
const headers: Record<string, string> = { accept: 'text/event-stream' };
|
|
153
|
+
const auth = await this.authorization();
|
|
154
|
+
if (auth) headers.authorization = `Bearer ${auth}`;
|
|
155
|
+
const res = await this.fetchImpl(this.url(path, query), { headers, signal });
|
|
156
|
+
if (!res.ok || !res.body)
|
|
157
|
+
throw new ApiError(res.status, `http_${res.status}`, `stream ${path} failed (HTTP ${res.status})`);
|
|
158
|
+
const decoder = new TextDecoder();
|
|
159
|
+
let buf = '';
|
|
160
|
+
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
161
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
162
|
+
let idx = buf.indexOf('\n\n');
|
|
163
|
+
while (idx !== -1) {
|
|
164
|
+
const frame = buf.slice(0, idx);
|
|
165
|
+
buf = buf.slice(idx + 2);
|
|
166
|
+
const data = frame
|
|
167
|
+
.split('\n')
|
|
168
|
+
.filter((l) => l.startsWith('data:'))
|
|
169
|
+
.map((l) => l.slice(5).trimStart())
|
|
170
|
+
.join('\n');
|
|
171
|
+
if (data) yield safeJson(data) as T;
|
|
172
|
+
idx = buf.indexOf('\n\n');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ------------------------------------------------------------- auth
|
|
178
|
+
login(req: CliLoginRequest) {
|
|
179
|
+
return this.request<CliLoginResponse>('POST', '/v1/auth/cli', { body: req });
|
|
180
|
+
}
|
|
181
|
+
me() {
|
|
182
|
+
return this.request<MeResponse>('GET', '/v1/me');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ------------------------------------------------------------- apps
|
|
186
|
+
async listApps(): Promise<AppSummary[]> {
|
|
187
|
+
return list(await this.request('GET', '/v1/apps'), 'apps');
|
|
188
|
+
}
|
|
189
|
+
getApp(app: string) {
|
|
190
|
+
return this.request<AppSummary>('GET', `/v1/apps/${enc(app)}`);
|
|
191
|
+
}
|
|
192
|
+
createApp(req: CreateAppRequest) {
|
|
193
|
+
return this.request<AppRecord>('POST', '/v1/apps', { body: req });
|
|
194
|
+
}
|
|
195
|
+
/** Unregister an app. The API refuses while any stage is still live. */
|
|
196
|
+
deleteApp(app: string) {
|
|
197
|
+
return this.request<unknown>('DELETE', `/v1/apps/${enc(app)}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ------------------------------------------------------------- stages & deployments
|
|
201
|
+
async listStages(app: string): Promise<StageRecord[]> {
|
|
202
|
+
return list(await this.request('GET', `/v1/apps/${enc(app)}/stages`), 'stages');
|
|
203
|
+
}
|
|
204
|
+
destroyStage(app: string, stage: string) {
|
|
205
|
+
// The API requires an explicit confirmation to destroy prod.
|
|
206
|
+
return this.request<unknown>('DELETE', `/v1/apps/${enc(app)}/stages/${enc(stage)}`, {
|
|
207
|
+
query: stage === 'prod' ? { confirm: app } : {},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
rollback(app: string, stage: string, toDeployment?: string) {
|
|
211
|
+
return this.request<DeploymentRecord | StageRecord>(
|
|
212
|
+
'POST',
|
|
213
|
+
`/v1/apps/${enc(app)}/stages/${enc(stage)}/rollback`,
|
|
214
|
+
{
|
|
215
|
+
body: toDeployment ? { deploymentId: toDeployment } : {},
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
createDeployment(app: string, req: CreateDeploymentRequest) {
|
|
220
|
+
return this.request<CreateDeploymentResponse>('POST', `/v1/apps/${enc(app)}/deployments`, { body: req });
|
|
221
|
+
}
|
|
222
|
+
startDeployment(id: string) {
|
|
223
|
+
return this.request<DeploymentRecord>('POST', `/v1/deployments/${enc(id)}/start`, { body: {} });
|
|
224
|
+
}
|
|
225
|
+
getDeployment(id: string) {
|
|
226
|
+
return this.request<DeploymentRecord>('GET', `/v1/deployments/${enc(id)}`);
|
|
227
|
+
}
|
|
228
|
+
async listDeployments(app: string, stage?: string): Promise<DeploymentRecord[]> {
|
|
229
|
+
return list(
|
|
230
|
+
await this.request('GET', `/v1/apps/${enc(app)}/deployments`, { query: { stage } }),
|
|
231
|
+
'deployments',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ------------------------------------------------------------- observability
|
|
236
|
+
metrics(app: string, q: { stage: string; from: number; to: number; res: Resolution }) {
|
|
237
|
+
return this.request<MetricsResponse>('GET', `/v1/apps/${enc(app)}/metrics`, { query: q });
|
|
238
|
+
}
|
|
239
|
+
async routes(app: string, q: { stage: string; from: number; to: number }): Promise<RouteStats[]> {
|
|
240
|
+
return list(await this.request('GET', `/v1/apps/${enc(app)}/routes`, { query: q }), 'routes');
|
|
241
|
+
}
|
|
242
|
+
async jobs(
|
|
243
|
+
app: string,
|
|
244
|
+
q: { stage: string; from: number; to: number },
|
|
245
|
+
): Promise<{ jobs: JobStats[]; queues: QueueStats[] }> {
|
|
246
|
+
const [body, queues] = await Promise.all([
|
|
247
|
+
this.request<{ jobs?: JobStats[] }>('GET', `/v1/apps/${enc(app)}/jobs`, { query: q }),
|
|
248
|
+
this.request<QueueStats[]>('GET', `/v1/apps/${enc(app)}/queues`, { query: { stage: q.stage } }),
|
|
249
|
+
]);
|
|
250
|
+
return { jobs: body?.jobs ?? [], queues: Array.isArray(queues) ? queues : [] };
|
|
251
|
+
}
|
|
252
|
+
async errors(app: string, q: { stage: string }): Promise<ErrorGroup[]> {
|
|
253
|
+
return list(await this.request('GET', `/v1/apps/${enc(app)}/errors`, { query: q }), 'errors');
|
|
254
|
+
}
|
|
255
|
+
logs(
|
|
256
|
+
app: string,
|
|
257
|
+
q: { stage: string; from: number; to?: number; filter?: string; nextToken?: string; limit?: number },
|
|
258
|
+
) {
|
|
259
|
+
const { filter, nextToken, ...rest } = q;
|
|
260
|
+
return this.request<LogsResponse>('GET', `/v1/apps/${enc(app)}/logs`, {
|
|
261
|
+
query: { ...rest, ...(filter ? { q: filter } : {}), ...(nextToken ? { next: nextToken } : {}) },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
tailLogs(app: string, q: { stage: string; filter?: string }, signal?: AbortSignal) {
|
|
265
|
+
return this.stream<LogLine>(
|
|
266
|
+
`/v1/apps/${enc(app)}/logs/tail`,
|
|
267
|
+
{ stage: q.stage, ...(q.filter ? { q: q.filter } : {}) },
|
|
268
|
+
signal,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
trace(app: string, trace: string, q: { stage: string }) {
|
|
272
|
+
return this.request<TraceResponse>('GET', `/v1/apps/${enc(app)}/traces/${enc(trace)}`, { query: q });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ------------------------------------------------------------- data
|
|
276
|
+
async collections(app: string, q: { stage: string }): Promise<string[]> {
|
|
277
|
+
const rows = list<string | { name: string }>(
|
|
278
|
+
await this.request('GET', `/v1/apps/${enc(app)}/data/collections`, { query: q }),
|
|
279
|
+
'collections',
|
|
280
|
+
);
|
|
281
|
+
return rows.map((r) => (typeof r === 'string' ? r : r.name));
|
|
282
|
+
}
|
|
283
|
+
items(
|
|
284
|
+
app: string,
|
|
285
|
+
q: { stage: string; collection: string; partition?: string; cursor?: string; limit?: number },
|
|
286
|
+
) {
|
|
287
|
+
return this.request<DataPage>('GET', `/v1/apps/${enc(app)}/data/items`, { query: q });
|
|
288
|
+
}
|
|
289
|
+
async files(app: string, q: { stage: string; store: string; prefix?: string; cursor?: string }) {
|
|
290
|
+
return this.request<{ objects: StoredObjectInfo[]; cursor?: string }>(
|
|
291
|
+
'GET',
|
|
292
|
+
`/v1/apps/${enc(app)}/storage`,
|
|
293
|
+
{ query: q },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ------------------------------------------------------------- secrets & jobs
|
|
298
|
+
async listSecrets(app: string, q: { stage: string }): Promise<SecretInfo[]> {
|
|
299
|
+
return list(await this.request('GET', `/v1/apps/${enc(app)}/secrets`, { query: q }), 'secrets');
|
|
300
|
+
}
|
|
301
|
+
setSecret(app: string, name: string, body: { value: string; stage?: string; shared?: boolean }) {
|
|
302
|
+
return this.request<SecretInfo>('PUT', `/v1/apps/${enc(app)}/secrets/${enc(name)}`, {
|
|
303
|
+
body: { value: body.value },
|
|
304
|
+
query: secretScope(body),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
deleteSecret(app: string, name: string, q: { stage?: string; shared?: boolean }) {
|
|
308
|
+
return this.request<unknown>('DELETE', `/v1/apps/${enc(app)}/secrets/${enc(name)}`, {
|
|
309
|
+
query: secretScope(q),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
runCron(app: string, name: string, stage: string) {
|
|
313
|
+
return this.request<unknown>('POST', `/v1/apps/${enc(app)}/crons/${enc(name)}/run`, {
|
|
314
|
+
body: {},
|
|
315
|
+
query: { stage },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const enc = encodeURIComponent;
|
|
321
|
+
|
|
322
|
+
/** The API scopes secrets with `?scope=shared` (app-wide) or `?stage=`. */
|
|
323
|
+
function secretScope(o: { stage?: string; shared?: boolean }): Record<string, string> {
|
|
324
|
+
return o.shared ? { scope: 'shared' } : o.stage ? { stage: o.stage } : {};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function safeJson(text: string): unknown {
|
|
328
|
+
try {
|
|
329
|
+
return JSON.parse(text);
|
|
330
|
+
} catch {
|
|
331
|
+
return text;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Accept `[...]` or `{ key: [...] }` / `{ items: [...] }`. */
|
|
336
|
+
function list<T>(body: unknown, key: string): T[] {
|
|
337
|
+
if (Array.isArray(body)) return body as T[];
|
|
338
|
+
const obj = (body ?? {}) as Record<string, unknown>;
|
|
339
|
+
const v = obj[key] ?? obj.items;
|
|
340
|
+
return Array.isArray(v) ? (v as T[]) : [];
|
|
341
|
+
}
|
package/src/bin.ts
ADDED
package/src/build.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
4
|
+
import type { ArtifactFile, ResolvedManifest } from '@ericminassian/platform-core';
|
|
5
|
+
import { build as esbuild, type Plugin } from 'esbuild';
|
|
6
|
+
import { cliVersion, loadProject, type Project } from './project.ts';
|
|
7
|
+
import { buildFailureList } from './util/esbuild.ts';
|
|
8
|
+
import { CliError, EXIT, step } from './util/output.ts';
|
|
9
|
+
import { dirExists, zipDirectory } from './util/zip.ts';
|
|
10
|
+
|
|
11
|
+
export const SDK_SERVER = '@ericminassian/platform/server';
|
|
12
|
+
export const SDK_WORKER = '@ericminassian/platform/worker';
|
|
13
|
+
|
|
14
|
+
export interface BuildOptions {
|
|
15
|
+
cwd: string;
|
|
16
|
+
/** Already-loaded project (skips evaluating plat.config.ts again). */
|
|
17
|
+
project?: Project;
|
|
18
|
+
/** Default `<app>/.plat/build`. */
|
|
19
|
+
outDir?: string;
|
|
20
|
+
/** Skip `web.build` (e.g. `plat dev`, which runs the dev server instead). */
|
|
21
|
+
skipWebBuild?: boolean;
|
|
22
|
+
/** Only build these parts. Default: everything the manifest declares. */
|
|
23
|
+
only?: Array<'server' | 'worker' | 'static'>;
|
|
24
|
+
/**
|
|
25
|
+
* Replace module specifiers during bundling (absolute paths). Tests use it to
|
|
26
|
+
* stand in for the SDK; nothing else should need it.
|
|
27
|
+
*/
|
|
28
|
+
resolveOverrides?: Record<string, string>;
|
|
29
|
+
minify?: boolean;
|
|
30
|
+
quiet?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuildResult {
|
|
34
|
+
manifest: ResolvedManifest;
|
|
35
|
+
/** Absolute paths of the produced artifacts. */
|
|
36
|
+
files: Partial<Record<ArtifactFile, string>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds an app into deployable artifacts:
|
|
41
|
+
* - `server.zip` → `index.mjs` (+ map): the app's fetch handler wrapped by the SDK server
|
|
42
|
+
* - `worker.zip` → `index.mjs` (+ map): `export const handler` for the worker Lambda
|
|
43
|
+
* - `static.zip` → the built frontend
|
|
44
|
+
* - `manifest.json` → `{ manifest, resolved, build }` (`manifest` is plat.config.ts evaluated to JSON)
|
|
45
|
+
*/
|
|
46
|
+
export async function buildApp(opts: BuildOptions): Promise<BuildResult> {
|
|
47
|
+
const project = opts.project ?? (await loadProject(opts.cwd));
|
|
48
|
+
const { root, manifest, raw } = project;
|
|
49
|
+
const outDir = resolve(opts.outDir ?? join(root, '.plat', 'build'));
|
|
50
|
+
const want = (part: 'server' | 'worker' | 'static') => !opts.only || opts.only.includes(part);
|
|
51
|
+
const log = opts.quiet ? () => {} : step;
|
|
52
|
+
|
|
53
|
+
validateForBuild(root, manifest);
|
|
54
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
55
|
+
mkdirSync(outDir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
const files: BuildResult['files'] = {};
|
|
58
|
+
const plugins = opts.resolveOverrides ? [overridePlugin(opts.resolveOverrides)] : [];
|
|
59
|
+
const minify = opts.minify ?? true;
|
|
60
|
+
|
|
61
|
+
if (manifest.web?.server && want('server')) {
|
|
62
|
+
log('bundling server');
|
|
63
|
+
const entry = resolve(root, manifest.web.server);
|
|
64
|
+
const dir = await bundle({
|
|
65
|
+
root,
|
|
66
|
+
outDir: join(outDir, 'server'),
|
|
67
|
+
contents: `import handler from ${JSON.stringify(entry)};\nimport { startServer } from ${JSON.stringify(SDK_SERVER)};\nawait startServer(handler);\n`,
|
|
68
|
+
plugins,
|
|
69
|
+
minify,
|
|
70
|
+
});
|
|
71
|
+
files['server.zip'] = writeZip(outDir, 'server.zip', zipDirectory(dir));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (manifest.worker && want('worker')) {
|
|
75
|
+
log('bundling worker');
|
|
76
|
+
const dir = await bundleWorker(root, manifest, join(outDir, 'worker'), { plugins, minify });
|
|
77
|
+
files['worker.zip'] = writeZip(outDir, 'worker.zip', zipDirectory(dir));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (manifest.web?.static && want('static')) {
|
|
81
|
+
if (manifest.web.build && !opts.skipWebBuild) {
|
|
82
|
+
log(`building frontend: ${manifest.web.build}`);
|
|
83
|
+
await runCommand(manifest.web.build, root);
|
|
84
|
+
}
|
|
85
|
+
const staticDir = resolve(root, manifest.web.static);
|
|
86
|
+
if (!dirExists(staticDir)) {
|
|
87
|
+
throw new CliError(`web.static directory ${manifest.web.static} does not exist`, {
|
|
88
|
+
hint: manifest.web.build
|
|
89
|
+
? 'check that web.build writes to web.static'
|
|
90
|
+
: 'add a web.build command or build the frontend first',
|
|
91
|
+
exitCode: EXIT.usage,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (!existsSync(join(staticDir, 'index.html')) && manifest.web.spa) {
|
|
95
|
+
throw new CliError(`${manifest.web.static}/index.html is missing but web.spa is true`, {
|
|
96
|
+
exitCode: EXIT.usage,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
files['static.zip'] = writeZip(outDir, 'static.zip', zipDirectory(staticDir));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const manifestPath = join(outDir, 'manifest.json');
|
|
103
|
+
writeFileSync(
|
|
104
|
+
manifestPath,
|
|
105
|
+
`${JSON.stringify(
|
|
106
|
+
{
|
|
107
|
+
manifest: raw,
|
|
108
|
+
resolved: manifest,
|
|
109
|
+
build: {
|
|
110
|
+
cli: cliVersion(),
|
|
111
|
+
builtAt: new Date().toISOString(),
|
|
112
|
+
files: Object.keys(files),
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
null,
|
|
116
|
+
2,
|
|
117
|
+
)}\n`,
|
|
118
|
+
);
|
|
119
|
+
files['manifest.json'] = manifestPath;
|
|
120
|
+
return { manifest, files };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Bundle just the worker into `outDir/index.mjs` (used by build and `plat dev`). */
|
|
124
|
+
export async function bundleWorker(
|
|
125
|
+
root: string,
|
|
126
|
+
manifest: ResolvedManifest,
|
|
127
|
+
outDir: string,
|
|
128
|
+
opts: { plugins?: Plugin[]; minify?: boolean } = {},
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
if (!manifest.worker) throw new Error('manifest has no worker');
|
|
131
|
+
const entry = resolve(root, manifest.worker.entry);
|
|
132
|
+
return bundle({
|
|
133
|
+
root,
|
|
134
|
+
outDir,
|
|
135
|
+
contents: `import def from ${JSON.stringify(entry)};\nimport { createWorkerHandler } from ${JSON.stringify(SDK_WORKER)};\nexport const handler = createWorkerHandler(def);\n`,
|
|
136
|
+
plugins: opts.plugins ?? [],
|
|
137
|
+
minify: opts.minify ?? true,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Zip of a bundled directory, for UpdateFunctionCode. */
|
|
142
|
+
export function zipBundle(dir: string): Uint8Array {
|
|
143
|
+
return zipDirectory(dir);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function validateForBuild(root: string, manifest: ResolvedManifest): void {
|
|
147
|
+
const problems: string[] = [];
|
|
148
|
+
if (manifest.auth.enabled && !manifest.web?.server)
|
|
149
|
+
problems.push('auth needs web.server (sign-in routes run in the server)');
|
|
150
|
+
if (manifest.web?.server && !existsSync(resolve(root, manifest.web.server))) {
|
|
151
|
+
problems.push(`web.server entry ${manifest.web.server} does not exist`);
|
|
152
|
+
}
|
|
153
|
+
if (manifest.worker && !existsSync(resolve(root, manifest.worker.entry))) {
|
|
154
|
+
problems.push(
|
|
155
|
+
`worker entry ${manifest.worker.entry} does not exist (needed for ${describeWorkerNeeds(manifest)})`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (problems.length)
|
|
159
|
+
throw new CliError(`can't build ${manifest.name}:\n - ${problems.join('\n - ')}`, {
|
|
160
|
+
exitCode: EXIT.usage,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function describeWorkerNeeds(m: ResolvedManifest): string {
|
|
165
|
+
const parts = [
|
|
166
|
+
...Object.keys(m.queues).map((q) => `queue ${q}`),
|
|
167
|
+
...m.events.map((e) => `event ${e}`),
|
|
168
|
+
...Object.keys(m.crons).map((c) => `cron ${c}`),
|
|
169
|
+
];
|
|
170
|
+
return parts.join(', ') || 'worker';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function bundle(o: {
|
|
174
|
+
root: string;
|
|
175
|
+
outDir: string;
|
|
176
|
+
contents: string;
|
|
177
|
+
plugins: Plugin[];
|
|
178
|
+
minify: boolean;
|
|
179
|
+
}): Promise<string> {
|
|
180
|
+
mkdirSync(o.outDir, { recursive: true });
|
|
181
|
+
try {
|
|
182
|
+
await esbuild({
|
|
183
|
+
stdin: { contents: o.contents, resolveDir: o.root, sourcefile: 'platform-entry.ts', loader: 'ts' },
|
|
184
|
+
outfile: join(o.outDir, 'index.mjs'),
|
|
185
|
+
bundle: true,
|
|
186
|
+
platform: 'node',
|
|
187
|
+
format: 'esm',
|
|
188
|
+
target: 'node24',
|
|
189
|
+
minify: o.minify,
|
|
190
|
+
sourcemap: 'external',
|
|
191
|
+
sourcesContent: false,
|
|
192
|
+
legalComments: 'none',
|
|
193
|
+
logLevel: 'silent',
|
|
194
|
+
// ESM bundles of CJS dependencies still call require(). process.getBuiltinModule
|
|
195
|
+
// (not a static `import 'node:module'`) keeps the bundle loadable in runtimes
|
|
196
|
+
// without node:module, such as the AWS Emulator's Lambda sandbox.
|
|
197
|
+
banner: {
|
|
198
|
+
js: "const require = globalThis.process?.getBuiltinModule?.('module')?.createRequire(import.meta.url);",
|
|
199
|
+
},
|
|
200
|
+
plugins: o.plugins,
|
|
201
|
+
absWorkingDir: o.root,
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
throw new CliError(`bundling failed:${buildFailureList(err)}`);
|
|
205
|
+
}
|
|
206
|
+
return o.outDir;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function overridePlugin(map: Record<string, string>): Plugin {
|
|
210
|
+
const keys = Object.keys(map);
|
|
211
|
+
const filter = new RegExp(`^(${keys.map((k) => k.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|')})$`);
|
|
212
|
+
return {
|
|
213
|
+
name: 'plat-resolve-overrides',
|
|
214
|
+
setup(b) {
|
|
215
|
+
b.onResolve({ filter }, (args) => ({ path: map[args.path]! }));
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function writeZip(outDir: string, name: string, data: Uint8Array): string {
|
|
221
|
+
const p = join(outDir, name);
|
|
222
|
+
writeFileSync(p, data);
|
|
223
|
+
return p;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Run a manifest shell command with the app's `node_modules/.bin` on PATH. */
|
|
227
|
+
export function runCommand(
|
|
228
|
+
command: string,
|
|
229
|
+
cwd: string,
|
|
230
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
return new Promise((resolvePromise, reject) => {
|
|
233
|
+
const child = spawn(command, {
|
|
234
|
+
cwd,
|
|
235
|
+
shell: true,
|
|
236
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
237
|
+
env: withLocalBin(cwd, env),
|
|
238
|
+
});
|
|
239
|
+
child.once('exit', (code) =>
|
|
240
|
+
code === 0 ? resolvePromise() : reject(new CliError(`\`${command}\` exited with code ${code}`)),
|
|
241
|
+
);
|
|
242
|
+
child.once('error', reject);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function withLocalBin(cwd: string, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
247
|
+
return { ...env, PATH: [join(cwd, 'node_modules', '.bin'), env.PATH ?? ''].join(delimiter) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function readBuildManifest(outDir: string): unknown {
|
|
251
|
+
return JSON.parse(readFileSync(join(outDir, 'manifest.json'), 'utf8'));
|
|
252
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { type Command, Option } from 'commander';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { ApiClient, apiBaseUrl } from '../api.ts';
|
|
5
|
+
import { clearCredentials, credentialsPath, loadCredentials, saveCredentials } from '../credentials.ts';
|
|
6
|
+
import { CliError, EXIT, result, success } from '../util/output.ts';
|
|
7
|
+
import { action, apiFor, type CommonOpts, withCommon } from './common.ts';
|
|
8
|
+
|
|
9
|
+
export function registerAccount(program: Command): void {
|
|
10
|
+
withCommon(
|
|
11
|
+
program
|
|
12
|
+
.command('login')
|
|
13
|
+
.description('sign in to the platform with your GitHub identity (uses `gh auth token`)')
|
|
14
|
+
.addOption(
|
|
15
|
+
new Option('--github-token <token>', 'GitHub token to use instead of `gh auth token`').env(
|
|
16
|
+
'PLAT_GITHUB_TOKEN',
|
|
17
|
+
),
|
|
18
|
+
),
|
|
19
|
+
).action(
|
|
20
|
+
action(async (opts: CommonOpts & { githubToken?: string }) => {
|
|
21
|
+
const baseUrl = opts.apiUrl ?? apiBaseUrl();
|
|
22
|
+
const githubToken = opts.githubToken ?? ghToken();
|
|
23
|
+
const api = new ApiClient({ baseUrl, auth: 'none' });
|
|
24
|
+
const res = await api.login({ githubToken });
|
|
25
|
+
saveCredentials({ apiUrl: baseUrl, token: res.token, github: res.github, expiresAt: res.expiresAt });
|
|
26
|
+
result({ github: res.github, role: res.role, expiresAt: res.expiresAt, apiUrl: baseUrl }, (r) => {
|
|
27
|
+
success(`signed in as ${pc.bold(r.github)} (${r.role}) until ${r.expiresAt}`);
|
|
28
|
+
return undefined;
|
|
29
|
+
});
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
withCommon(program.command('logout').description('forget the stored platform login')).action(
|
|
34
|
+
action(async () => {
|
|
35
|
+
const removed = clearCredentials();
|
|
36
|
+
result({ loggedOut: removed, path: credentialsPath() }, (r) =>
|
|
37
|
+
r.loggedOut ? 'logged out' : 'not logged in',
|
|
38
|
+
);
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
withCommon(program.command('whoami').description('show the signed-in platform member')).action(
|
|
43
|
+
action(async (opts: CommonOpts) => {
|
|
44
|
+
const me = await apiFor(opts).me();
|
|
45
|
+
const stored = loadCredentials();
|
|
46
|
+
result({ ...me, expiresAt: stored?.expiresAt }, (r) => `${pc.bold(r.github)} (${r.role})`);
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ghToken(): string {
|
|
52
|
+
const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
53
|
+
const r = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8' });
|
|
54
|
+
const token = r.status === 0 ? r.stdout.trim() : env;
|
|
55
|
+
if (!token) {
|
|
56
|
+
throw new CliError('could not get a GitHub token', {
|
|
57
|
+
hint: 'install the GitHub CLI and run `gh auth login`, or pass --github-token',
|
|
58
|
+
exitCode: EXIT.auth,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return token;
|
|
62
|
+
}
|