@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.
- 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 +45 -0
- package/dist/build.js +188 -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 +105 -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 +23 -0
- package/dist/init.js +102 -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 +22 -0
- package/dist/project.js +81 -0
- package/dist/project.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 +255 -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 +139 -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 +123 -0
- package/src/main.ts +28 -0
- package/src/project.ts +89 -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 +134 -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/platform.json +18 -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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { LogLine, TelemetryEvent } from '@ericminassian/platform-core';
|
|
3
|
+
import type { Command } from 'commander';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { formatEvent } from '../dev/pretty.ts';
|
|
6
|
+
import { CliError, EXIT, isJsonMode, result, sparkline, table } from '../util/output.ts';
|
|
7
|
+
import { shortTime, sinceRange } from '../util/time.ts';
|
|
8
|
+
import { action, apiFor, appFor, type CommonOpts, withTarget } from './common.ts';
|
|
9
|
+
|
|
10
|
+
type TargetOpts = CommonOpts & { stage: string };
|
|
11
|
+
|
|
12
|
+
export function registerObserve(program: Command): void {
|
|
13
|
+
withTarget(
|
|
14
|
+
program
|
|
15
|
+
.command('logs')
|
|
16
|
+
.description('search recent logs (web + worker); --follow streams live')
|
|
17
|
+
.option('--since <duration>', 'how far back', '1h')
|
|
18
|
+
.option('--filter <text>', 'only lines containing this text / CloudWatch filter pattern')
|
|
19
|
+
.option('--limit <n>', 'max lines', '200')
|
|
20
|
+
.option('-f, --follow', 'stream new lines (live tail)')
|
|
21
|
+
.option('--runtime', "include the Lambda runtime's own records (hidden by default)"),
|
|
22
|
+
).action(
|
|
23
|
+
action(
|
|
24
|
+
async (
|
|
25
|
+
opts: TargetOpts & {
|
|
26
|
+
since: string;
|
|
27
|
+
filter?: string;
|
|
28
|
+
limit: string;
|
|
29
|
+
follow?: boolean;
|
|
30
|
+
runtime?: boolean;
|
|
31
|
+
},
|
|
32
|
+
) => {
|
|
33
|
+
const keep = (l: LogLine) => opts.runtime || l.level !== 'system';
|
|
34
|
+
const api = apiFor(opts);
|
|
35
|
+
const app = appFor(opts);
|
|
36
|
+
if (opts.follow) {
|
|
37
|
+
const ac = new AbortController();
|
|
38
|
+
process.once('SIGINT', () => ac.abort());
|
|
39
|
+
try {
|
|
40
|
+
for await (const line of api.tailLogs(
|
|
41
|
+
app,
|
|
42
|
+
{ stage: opts.stage, ...(opts.filter ? { filter: opts.filter } : {}) },
|
|
43
|
+
ac.signal,
|
|
44
|
+
)) {
|
|
45
|
+
if (!keep(line)) continue;
|
|
46
|
+
process.stdout.write(`${isJsonMode() ? JSON.stringify(line) : formatLogLine(line)}\n`);
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if ((err as Error).name !== 'AbortError') throw err;
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const { from, to } = sinceRange(opts.since);
|
|
54
|
+
const res = await api.logs(app, {
|
|
55
|
+
stage: opts.stage,
|
|
56
|
+
from,
|
|
57
|
+
to,
|
|
58
|
+
limit: Number(opts.limit),
|
|
59
|
+
...(opts.filter ? { filter: opts.filter } : {}),
|
|
60
|
+
});
|
|
61
|
+
const shown = { ...res, lines: res.lines.filter(keep) };
|
|
62
|
+
result(shown, (r) =>
|
|
63
|
+
r.lines.length ? r.lines.map(formatLogLine).join('\n') : pc.dim('(no log lines)'),
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
withTarget(
|
|
70
|
+
program
|
|
71
|
+
.command('metrics')
|
|
72
|
+
.description('requests, errors, latency and jobs for a stage')
|
|
73
|
+
.option('--since <duration>', 'time range', '6h'),
|
|
74
|
+
).action(
|
|
75
|
+
action(async (opts: TargetOpts & { since: string }) => {
|
|
76
|
+
const api = apiFor(opts);
|
|
77
|
+
const app = appFor(opts);
|
|
78
|
+
const range = sinceRange(opts.since);
|
|
79
|
+
const [metrics, routes, jobs] = await Promise.all([
|
|
80
|
+
api.metrics(app, { stage: opts.stage, ...range }),
|
|
81
|
+
api.routes(app, { stage: opts.stage, from: range.from, to: range.to }).catch(() => []),
|
|
82
|
+
api
|
|
83
|
+
.jobs(app, { stage: opts.stage, from: range.from, to: range.to })
|
|
84
|
+
.catch(() => ({ jobs: [], queues: [] })),
|
|
85
|
+
]);
|
|
86
|
+
result({ metrics, routes, ...jobs }, (v) => {
|
|
87
|
+
const pts = v.metrics.points;
|
|
88
|
+
const total = pts.reduce((a, p) => a + p.req, 0);
|
|
89
|
+
const errors = pts.reduce((a, p) => a + p.s5xx, 0);
|
|
90
|
+
const p95s = pts.map((p) => p.p95).filter((x): x is number => x !== null);
|
|
91
|
+
const lines = [
|
|
92
|
+
`${pc.bold(app)} ${pc.dim(`${opts.stage} · last ${opts.since} · ${v.metrics.res} buckets`)}`,
|
|
93
|
+
'',
|
|
94
|
+
` requests ${String(total).padStart(8)} ${sparkline(pts.map((p) => p.req))}`,
|
|
95
|
+
` 5xx ${String(errors).padStart(8)} ${pc.red(sparkline(pts.map((p) => p.s5xx)))}`,
|
|
96
|
+
` p95 (ms) ${String(p95s.length ? Math.max(...p95s) : '-').padStart(8)} ${sparkline(pts.map((p) => p.p95))}`,
|
|
97
|
+
` cold ${String(pts.reduce((a, p) => a + p.cold, 0)).padStart(8)} ${sparkline(pts.map((p) => p.cold))}`,
|
|
98
|
+
];
|
|
99
|
+
if (v.routes.length) {
|
|
100
|
+
lines.push(
|
|
101
|
+
'',
|
|
102
|
+
pc.bold('routes'),
|
|
103
|
+
table(
|
|
104
|
+
v.routes.map((r) => ({
|
|
105
|
+
method: r.method,
|
|
106
|
+
route: r.route,
|
|
107
|
+
req: r.req,
|
|
108
|
+
'5xx': r.s5xx,
|
|
109
|
+
'4xx': r.s4xx,
|
|
110
|
+
p50: r.p50 ?? '-',
|
|
111
|
+
p95: r.p95 ?? '-',
|
|
112
|
+
})),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (v.jobs.length) {
|
|
117
|
+
lines.push(
|
|
118
|
+
'',
|
|
119
|
+
pc.bold('jobs'),
|
|
120
|
+
table(
|
|
121
|
+
v.jobs.map((j) => ({
|
|
122
|
+
job: `${j.kind}:${j.name}`,
|
|
123
|
+
ok: j.ok,
|
|
124
|
+
error: j.error,
|
|
125
|
+
p95: j.p95 ?? '-',
|
|
126
|
+
last: j.lastRun ? shortTime(j.lastRun) : '-',
|
|
127
|
+
})),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (v.queues.length) {
|
|
132
|
+
lines.push(
|
|
133
|
+
'',
|
|
134
|
+
pc.bold('queues'),
|
|
135
|
+
table(
|
|
136
|
+
v.queues.map((q) => ({
|
|
137
|
+
queue: q.name,
|
|
138
|
+
visible: q.visible,
|
|
139
|
+
inFlight: q.inFlight,
|
|
140
|
+
dlq: q.deadLetter,
|
|
141
|
+
oldest: q.oldestAgeSeconds ?? '-',
|
|
142
|
+
})),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
});
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
withTarget(program.command('errors').description('grouped errors for a stage')).action(
|
|
152
|
+
action(async (opts: TargetOpts) => {
|
|
153
|
+
const groups = await apiFor(opts).errors(appFor(opts), { stage: opts.stage });
|
|
154
|
+
result(groups, (g) =>
|
|
155
|
+
table(
|
|
156
|
+
g.map((e) => ({
|
|
157
|
+
id: e.fingerprint,
|
|
158
|
+
status: e.status === 'open' ? pc.red('open') : e.status,
|
|
159
|
+
count: e.count,
|
|
160
|
+
error: `${e.name}: ${e.message}`.slice(0, 80),
|
|
161
|
+
where: e.sample.route ?? e.sample.job ?? '',
|
|
162
|
+
last: shortTime(e.lastSeen),
|
|
163
|
+
trace: e.sample.trace ?? '',
|
|
164
|
+
})),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
withTarget(
|
|
171
|
+
program
|
|
172
|
+
.command('traces <traceId>')
|
|
173
|
+
.description('every event of one request/job across web, queues and worker'),
|
|
174
|
+
).action(
|
|
175
|
+
action(async (traceId: string, opts: TargetOpts) => {
|
|
176
|
+
const t = await apiFor(opts).trace(appFor(opts), traceId, { stage: opts.stage });
|
|
177
|
+
result(t, (v) => formatTrace(v.events));
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
withTarget(
|
|
182
|
+
program
|
|
183
|
+
.command('data [collection]')
|
|
184
|
+
.description('browse database collections or file stores')
|
|
185
|
+
.option('--partition <k=v...>', 'partition key values (repeatable)')
|
|
186
|
+
.option('--store <name>', 'list files in a storage store instead')
|
|
187
|
+
.option('--prefix <prefix>', 'file key prefix (with --store)')
|
|
188
|
+
.option('--cursor <cursor>', 'continue from a previous page')
|
|
189
|
+
.option('--limit <n>', 'page size', '50'),
|
|
190
|
+
).action(
|
|
191
|
+
action(
|
|
192
|
+
async (
|
|
193
|
+
collection: string | undefined,
|
|
194
|
+
opts: TargetOpts & {
|
|
195
|
+
partition?: string[];
|
|
196
|
+
store?: string;
|
|
197
|
+
prefix?: string;
|
|
198
|
+
cursor?: string;
|
|
199
|
+
limit: string;
|
|
200
|
+
},
|
|
201
|
+
) => {
|
|
202
|
+
const api = apiFor(opts);
|
|
203
|
+
const app = appFor(opts);
|
|
204
|
+
if (opts.store) {
|
|
205
|
+
const r = await api.files(app, {
|
|
206
|
+
stage: opts.stage,
|
|
207
|
+
store: opts.store,
|
|
208
|
+
...(opts.prefix ? { prefix: opts.prefix } : {}),
|
|
209
|
+
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
|
210
|
+
});
|
|
211
|
+
result(
|
|
212
|
+
r,
|
|
213
|
+
(v) =>
|
|
214
|
+
`${table(v.objects.map((o) => ({ key: o.key, size: o.size, modified: o.lastModified ?? '' })))}${v.cursor ? `\n${pc.dim(`more: --cursor ${v.cursor}`)}` : ''}`,
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!collection) {
|
|
219
|
+
const cols = await api.collections(app, { stage: opts.stage });
|
|
220
|
+
result(cols, (c) => (c.length ? c.join('\n') : pc.dim('(no collections yet)')));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const page = await api.items(app, {
|
|
224
|
+
stage: opts.stage,
|
|
225
|
+
collection,
|
|
226
|
+
limit: Number(opts.limit),
|
|
227
|
+
...(opts.partition ? { partition: parsePartition(opts.partition) } : {}),
|
|
228
|
+
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
|
229
|
+
});
|
|
230
|
+
result(
|
|
231
|
+
page,
|
|
232
|
+
(v) =>
|
|
233
|
+
`${v.items.map((i) => JSON.stringify(i)).join('\n') || pc.dim('(no items)')}${v.cursor ? `\n${pc.dim(`more: --cursor ${v.cursor}`)}` : ''}`,
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `["userId=1", "kind=a"]` → `"userId=1&kind=a"` (URL-encoded pairs, as the API expects). */
|
|
241
|
+
export function parsePartition(pairs: string[]): string {
|
|
242
|
+
const params = new URLSearchParams();
|
|
243
|
+
for (const p of pairs) {
|
|
244
|
+
const i = p.indexOf('=');
|
|
245
|
+
if (i <= 0) throw new CliError(`--partition expects key=value, got "${p}"`, { exitCode: EXIT.usage });
|
|
246
|
+
params.append(p.slice(0, i), p.slice(i + 1));
|
|
247
|
+
}
|
|
248
|
+
return params.toString();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function formatLogLine(l: LogLine): string {
|
|
252
|
+
const ts = pc.dim(shortTime(l.ts));
|
|
253
|
+
const stream = l.stream === 'web' ? pc.green('web ') : pc.yellow('worker');
|
|
254
|
+
const body = l.event ? (formatEvent(l.event, { spans: true }) ?? l.message) : l.message;
|
|
255
|
+
return `${ts} ${stream} ${body}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function formatTrace(events: TelemetryEvent[]): string {
|
|
259
|
+
if (events.length === 0) return pc.dim('(no events for this trace)');
|
|
260
|
+
const sorted = [...events].sort((a, b) => a.ts - b.ts);
|
|
261
|
+
const t0 = sorted[0]!.ts;
|
|
262
|
+
return sorted
|
|
263
|
+
.map((e) => {
|
|
264
|
+
const offset = `+${String(e.ts - t0).padStart(5)}ms`;
|
|
265
|
+
return `${pc.dim(offset)} ${formatEvent(e, { spans: true }) ?? e.t}`;
|
|
266
|
+
})
|
|
267
|
+
.join('\n');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function readStdin(): string {
|
|
271
|
+
try {
|
|
272
|
+
return readFileSync(0, 'utf8').replace(/\r?\n$/, '');
|
|
273
|
+
} catch {
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** What `plat login` stores. The token is a platform token, never a GitHub token. */
|
|
6
|
+
export interface StoredCredentials {
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
token: string;
|
|
9
|
+
github: string;
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function configDir(): string {
|
|
14
|
+
if (process.env.PLAT_CONFIG_DIR) return process.env.PLAT_CONFIG_DIR;
|
|
15
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
16
|
+
return join(base, 'plat');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function credentialsPath(): string {
|
|
20
|
+
return join(configDir(), 'credentials.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function saveCredentials(creds: StoredCredentials, path = credentialsPath()): void {
|
|
24
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
25
|
+
writeFileSync(path, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
|
|
26
|
+
// writeFileSync only applies `mode` when creating; enforce it on overwrite too.
|
|
27
|
+
chmodSync(path, 0o600);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadCredentials(path = credentialsPath()): StoredCredentials | null {
|
|
31
|
+
if (!existsSync(path)) return null;
|
|
32
|
+
try {
|
|
33
|
+
const c = JSON.parse(readFileSync(path, 'utf8')) as StoredCredentials;
|
|
34
|
+
if (!c.token || !c.apiUrl) return null;
|
|
35
|
+
return c;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearCredentials(path = credentialsPath()): boolean {
|
|
42
|
+
if (!existsSync(path)) return false;
|
|
43
|
+
rmSync(path);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isExpired(c: StoredCredentials, now = Date.now()): boolean {
|
|
48
|
+
const t = Date.parse(c.expiresAt);
|
|
49
|
+
return Number.isFinite(t) && t <= now;
|
|
50
|
+
}
|
package/src/deploy.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import type {
|
|
4
|
+
ArtifactFile,
|
|
5
|
+
DeploymentEventEntry,
|
|
6
|
+
DeploymentRecord,
|
|
7
|
+
DeploymentStatus,
|
|
8
|
+
ManifestInput,
|
|
9
|
+
PresignedPost,
|
|
10
|
+
} from '@ericminassian/platform-core';
|
|
11
|
+
import pc from 'picocolors';
|
|
12
|
+
import type { ApiClient } from './api.ts';
|
|
13
|
+
import { buildApp } from './build.ts';
|
|
14
|
+
import { appendStepSummary, deriveStage, gitInfo, isGitHubActions, setOutput } from './github.ts';
|
|
15
|
+
import { loadProject } from './project.ts';
|
|
16
|
+
import { CliError, EXIT, info, step, success } from './util/output.ts';
|
|
17
|
+
|
|
18
|
+
export const TERMINAL_STATUSES: readonly DeploymentStatus[] = ['succeeded', 'failed', 'rolled_back'];
|
|
19
|
+
|
|
20
|
+
export interface DeployOptions {
|
|
21
|
+
cwd: string;
|
|
22
|
+
stage?: string;
|
|
23
|
+
wait: boolean;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
pollMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeployResult {
|
|
29
|
+
deployment: DeploymentRecord;
|
|
30
|
+
url?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function deploy(api: ApiClient, opts: DeployOptions): Promise<DeployResult> {
|
|
34
|
+
const project = loadProject(opts.cwd);
|
|
35
|
+
const stage = deriveStage(opts.stage);
|
|
36
|
+
const app = project.manifest.name;
|
|
37
|
+
|
|
38
|
+
const { files } = await buildApp({ cwd: project.root });
|
|
39
|
+
// The API always issues an upload for manifest.json; `files` lists the optional bundles.
|
|
40
|
+
const fileNames = (Object.keys(files) as ArtifactFile[]).filter((f) => f !== 'manifest.json');
|
|
41
|
+
const git = gitInfo(project.root);
|
|
42
|
+
|
|
43
|
+
step(`creating deployment of ${pc.bold(app)} to ${pc.bold(stage)}`);
|
|
44
|
+
const created = await api.createDeployment(app, {
|
|
45
|
+
stage,
|
|
46
|
+
manifest: project.raw as ManifestInput,
|
|
47
|
+
files: fileNames,
|
|
48
|
+
...git,
|
|
49
|
+
});
|
|
50
|
+
const id = created.deployment.id;
|
|
51
|
+
|
|
52
|
+
for (const name of Object.keys(files) as ArtifactFile[]) {
|
|
53
|
+
const post = created.uploads[name];
|
|
54
|
+
if (!post) throw new CliError(`the API did not return an upload URL for ${name}`);
|
|
55
|
+
step(`uploading ${name}`);
|
|
56
|
+
await uploadPresignedPost(post, files[name]!, name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
step('starting deployment');
|
|
60
|
+
let deployment = await api.startDeployment(id);
|
|
61
|
+
if (!opts.wait) {
|
|
62
|
+
info(`deployment ${id} is ${deployment.status}; follow with \`plat deployments --stage ${stage}\``);
|
|
63
|
+
return { deployment, url: deployment.url };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
deployment = await waitForDeployment(api, id, {
|
|
67
|
+
timeoutMs: opts.timeoutMs ?? 15 * 60_000,
|
|
68
|
+
pollMs: opts.pollMs ?? 2_000,
|
|
69
|
+
onEvent: printEvent,
|
|
70
|
+
});
|
|
71
|
+
if (deployment.status !== 'succeeded') {
|
|
72
|
+
throw new CliError(
|
|
73
|
+
`deployment ${id} ${deployment.status}${deployment.error ? `: ${deployment.error}` : ''}`,
|
|
74
|
+
{
|
|
75
|
+
hint: `see \`plat logs --stage ${stage}\` and the events above`,
|
|
76
|
+
exitCode: EXIT.deployFailed,
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const url = deployment.url;
|
|
81
|
+
success(`deployed ${app} to ${stage}${url ? `: ${pc.underline(url)}` : ''}`);
|
|
82
|
+
if (isGitHubActions()) {
|
|
83
|
+
if (url) setOutput('url', url);
|
|
84
|
+
setOutput('deployment', id);
|
|
85
|
+
appendStepSummary(
|
|
86
|
+
`### Deployed \`${app}\` → \`${stage}\`\n\n${url ? `**URL:** ${url}\n\n` : ''}Deployment \`${id}\`${git.commit ? ` at \`${git.commit.slice(0, 7)}\`` : ''}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return { deployment, url };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Builds the multipart body for an S3 presigned POST (policy fields first, file last). */
|
|
93
|
+
export function presignedPostForm(post: PresignedPost, data: Uint8Array, filename: string): FormData {
|
|
94
|
+
const form = new FormData();
|
|
95
|
+
for (const [k, v] of Object.entries(post.fields)) form.append(k, v);
|
|
96
|
+
form.append('file', new Blob([new Uint8Array(data)]), filename);
|
|
97
|
+
return form;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function uploadPresignedPost(post: PresignedPost, path: string, name: string): Promise<void> {
|
|
101
|
+
const data = readFileSync(path);
|
|
102
|
+
const res = await fetch(post.url, { method: 'POST', body: presignedPostForm(post, data, basename(name)) });
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const text = await res.text().catch(() => '');
|
|
105
|
+
throw new CliError(`upload of ${name} failed (HTTP ${res.status})`, {
|
|
106
|
+
hint: text.slice(0, 300) || undefined,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function waitForDeployment(
|
|
112
|
+
api: ApiClient,
|
|
113
|
+
id: string,
|
|
114
|
+
opts: { timeoutMs: number; pollMs: number; onEvent?: (e: DeploymentEventEntry) => void },
|
|
115
|
+
): Promise<DeploymentRecord> {
|
|
116
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
117
|
+
let seen = 0;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const d = await api.getDeployment(id);
|
|
120
|
+
const events = d.events ?? [];
|
|
121
|
+
for (const e of events.slice(seen)) opts.onEvent?.(e);
|
|
122
|
+
seen = Math.max(seen, events.length);
|
|
123
|
+
if (TERMINAL_STATUSES.includes(d.status)) return d;
|
|
124
|
+
if (Date.now() > deadline) {
|
|
125
|
+
throw new CliError(`timed out waiting for deployment ${id} (status ${d.status})`, {
|
|
126
|
+
exitCode: EXIT.deployFailed,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
await new Promise((r) => setTimeout(r, opts.pollMs));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function printEvent(e: DeploymentEventEntry): void {
|
|
134
|
+
const color = e.level === 'error' ? pc.red : e.level === 'warn' ? pc.yellow : pc.dim;
|
|
135
|
+
info(
|
|
136
|
+
` ${color(new Date(e.ts).toISOString().slice(11, 19))} ${e.msg}${e.resource ? pc.dim(` (${e.resource})`) : ''}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
package/src/dev/crons.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Local stand-in for EventBridge Scheduler (the emulator creates schedules but
|
|
5
|
+
* never fires them). Same expressions as platform.json: 5-field cron in UTC
|
|
6
|
+
* (or the cron's timezone) or `rate(n unit)`.
|
|
7
|
+
*/
|
|
8
|
+
export interface LocalCron {
|
|
9
|
+
name: string;
|
|
10
|
+
schedule: string;
|
|
11
|
+
timezone?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function nextRun(cron: LocalCron, from = new Date()): Date {
|
|
15
|
+
const rate = /^rate\(\s*(\d+)\s+(minute|minutes|hour|hours|day|days)\s*\)$/.exec(cron.schedule.trim());
|
|
16
|
+
if (rate) {
|
|
17
|
+
const unit = rate[2]!.startsWith('minute')
|
|
18
|
+
? 60_000
|
|
19
|
+
: rate[2]!.startsWith('hour')
|
|
20
|
+
? 3_600_000
|
|
21
|
+
: 86_400_000;
|
|
22
|
+
return new Date(from.getTime() + Number(rate[1]) * unit);
|
|
23
|
+
}
|
|
24
|
+
return CronExpressionParser.parse(cron.schedule, { currentDate: from, tz: cron.timezone ?? 'UTC' })
|
|
25
|
+
.next()
|
|
26
|
+
.toDate();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CronScheduler {
|
|
30
|
+
next(name: string): Date | undefined;
|
|
31
|
+
stop(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function scheduleCrons(
|
|
35
|
+
crons: LocalCron[],
|
|
36
|
+
fire: (name: string) => void | Promise<void>,
|
|
37
|
+
): CronScheduler {
|
|
38
|
+
const timers = new Map<string, NodeJS.Timeout>();
|
|
39
|
+
const nexts = new Map<string, Date>();
|
|
40
|
+
const arm = (c: LocalCron) => {
|
|
41
|
+
const at = nextRun(c);
|
|
42
|
+
nexts.set(c.name, at);
|
|
43
|
+
// setTimeout caps at ~24.8 days; re-arm in steps.
|
|
44
|
+
const delay = Math.min(at.getTime() - Date.now(), 2 ** 31 - 1);
|
|
45
|
+
timers.set(
|
|
46
|
+
c.name,
|
|
47
|
+
setTimeout(
|
|
48
|
+
async () => {
|
|
49
|
+
if (Date.now() >= at.getTime() - 5) {
|
|
50
|
+
try {
|
|
51
|
+
await fire(c.name);
|
|
52
|
+
} catch {
|
|
53
|
+
// reported by `fire`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
arm(c);
|
|
57
|
+
},
|
|
58
|
+
Math.max(delay, 0),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
for (const c of crons) arm(c);
|
|
63
|
+
return {
|
|
64
|
+
next: (name) => nexts.get(name),
|
|
65
|
+
stop: () => {
|
|
66
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
67
|
+
timers.clear();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/** Path to tsx's CLI (it has no `./cli` export, so go through its package.json). */
|
|
7
|
+
export function tsxCli(): string {
|
|
8
|
+
const pkg = createRequire(import.meta.url).resolve('tsx/package.json');
|
|
9
|
+
const bin = (JSON.parse(readFileSync(pkg, 'utf8')) as { bin: string | Record<string, string> }).bin;
|
|
10
|
+
const rel = typeof bin === 'string' ? bin : (bin.tsx ?? Object.values(bin)[0]!);
|
|
11
|
+
return join(dirname(pkg), rel);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A CLI-owned entry file next to this module (`.ts` in the repo, `.js` when published). */
|
|
15
|
+
export function devEntry(name: 'server-entry' | 'worker-host'): string {
|
|
16
|
+
const ts = fileURLToPath(new URL(`./${name}.ts`, import.meta.url));
|
|
17
|
+
return existsSync(ts) ? ts : fileURLToPath(new URL(`./${name}.js`, import.meta.url));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** argv for running an entry under tsx, optionally restarting on edits. */
|
|
21
|
+
export function tsxArgs(name: 'server-entry' | 'worker-host', watch: boolean): string[] {
|
|
22
|
+
return watch ? [tsxCli(), 'watch', '--clear-screen=false', devEntry(name)] : [tsxCli(), devEntry(name)];
|
|
23
|
+
}
|