@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.
Files changed (160) 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 +48 -0
  11. package/dist/build.js +185 -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 +106 -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 +30 -0
  77. package/dist/init.js +114 -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 +21 -0
  83. package/dist/project.js +138 -0
  84. package/dist/project.js.map +1 -0
  85. package/dist/util/esbuild.d.ts +2 -0
  86. package/dist/util/esbuild.js +9 -0
  87. package/dist/util/esbuild.js.map +1 -0
  88. package/dist/util/net.d.ts +4 -0
  89. package/dist/util/net.js +33 -0
  90. package/dist/util/net.js.map +1 -0
  91. package/dist/util/output.d.ts +42 -0
  92. package/dist/util/output.js +105 -0
  93. package/dist/util/output.js.map +1 -0
  94. package/dist/util/prompt.d.ts +3 -0
  95. package/dist/util/prompt.js +22 -0
  96. package/dist/util/prompt.js.map +1 -0
  97. package/dist/util/time.d.ts +11 -0
  98. package/dist/util/time.js +29 -0
  99. package/dist/util/time.js.map +1 -0
  100. package/dist/util/zip.d.ts +10 -0
  101. package/dist/util/zip.js +45 -0
  102. package/dist/util/zip.js.map +1 -0
  103. package/package.json +50 -3
  104. package/src/api-types.ts +8 -0
  105. package/src/api.ts +341 -0
  106. package/src/bin.ts +4 -0
  107. package/src/build.ts +252 -0
  108. package/src/commands/account.ts +62 -0
  109. package/src/commands/apps.ts +102 -0
  110. package/src/commands/common.ts +51 -0
  111. package/src/commands/lifecycle.ts +140 -0
  112. package/src/commands/manage.ts +169 -0
  113. package/src/commands/observe.ts +276 -0
  114. package/src/credentials.ts +50 -0
  115. package/src/deploy.ts +138 -0
  116. package/src/dev/crons.ts +70 -0
  117. package/src/dev/entries.ts +23 -0
  118. package/src/dev/index.ts +281 -0
  119. package/src/dev/local-stage.ts +176 -0
  120. package/src/dev/pretty.ts +90 -0
  121. package/src/dev/proc.ts +15 -0
  122. package/src/dev/router.ts +246 -0
  123. package/src/dev/server-entry.ts +21 -0
  124. package/src/dev/test.ts +68 -0
  125. package/src/dev/worker-host.ts +62 -0
  126. package/src/dev/worker.ts +332 -0
  127. package/src/github.ts +121 -0
  128. package/src/index.ts +20 -0
  129. package/src/init.ts +136 -0
  130. package/src/main.ts +28 -0
  131. package/src/project.ts +143 -0
  132. package/src/util/esbuild.ts +10 -0
  133. package/src/util/net.ts +33 -0
  134. package/src/util/output.ts +117 -0
  135. package/src/util/prompt.ts +20 -0
  136. package/src/util/time.ts +32 -0
  137. package/src/util/zip.ts +46 -0
  138. package/template/.github/workflows/platform.yml +24 -0
  139. package/template/AGENTS.md +139 -0
  140. package/template/CLAUDE.md +1 -0
  141. package/template/README.md +13 -0
  142. package/template/_gitignore +8 -0
  143. package/template/package.json +31 -0
  144. package/template/plat.config.ts +31 -0
  145. package/template/src/server.ts +100 -0
  146. package/template/src/types.ts +21 -0
  147. package/template/src/worker.ts +32 -0
  148. package/template/test/server.test.ts +120 -0
  149. package/template/test/todos.integration.test.ts +30 -0
  150. package/template/tsconfig.json +18 -0
  151. package/template/vitest.config.ts +9 -0
  152. package/template/vitest.integration.config.ts +10 -0
  153. package/template/web/index.html +12 -0
  154. package/template/web/src/App.tsx +102 -0
  155. package/template/web/src/api.ts +11 -0
  156. package/template/web/src/main.tsx +10 -0
  157. package/template/web/src/styles.css +102 -0
  158. package/template/web/tsconfig.json +18 -0
  159. package/template/web/vite.config.ts +19 -0
  160. package/README.md +0 -3
@@ -0,0 +1,332 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process';
2
+ import { resolve } from 'node:path';
3
+ import {
4
+ EventBridgeClient,
5
+ ListRulesCommand,
6
+ ListTargetsByRuleCommand,
7
+ PutTargetsCommand,
8
+ RemoveTargetsCommand,
9
+ } from '@aws-sdk/client-eventbridge';
10
+ import {
11
+ LambdaClient,
12
+ ListEventSourceMappingsCommand,
13
+ UpdateEventSourceMappingCommand,
14
+ } from '@aws-sdk/client-lambda';
15
+ import {
16
+ CreateQueueCommand,
17
+ DeleteMessageBatchCommand,
18
+ GetQueueAttributesCommand,
19
+ type Message,
20
+ ReceiveMessageCommand,
21
+ SetQueueAttributesCommand,
22
+ SQSClient,
23
+ } from '@aws-sdk/client-sqs';
24
+ import { names, type ResolvedManifest } from '@ericminassian/platform-core';
25
+ import { SDK_WORKER } from '../build.ts';
26
+ import { resolveFromApp } from '../project.ts';
27
+ import { freePort, waitForPort } from '../util/net.ts';
28
+ import { tsxArgs } from './entries.ts';
29
+ import { emulatorClientConfig, type LocalStage, localEnv } from './local-stage.ts';
30
+ import { killTree } from './proc.ts';
31
+
32
+ /**
33
+ * Local worker: the app's worker in a real Node process (see worker-host.ts
34
+ * for why), fed by a dispatcher that does what Lambda's event sources would:
35
+ * polls each SQS queue (batches, partial failures, retries via visibility
36
+ * timeout, DLQ via the queue's redrive policy), receives EventBridge events
37
+ * through a tap queue, and runs crons on demand.
38
+ */
39
+ export interface InvokeResult {
40
+ ok: boolean;
41
+ result?: unknown;
42
+ error?: { name: string; message: string; stack?: string };
43
+ }
44
+
45
+ export interface LocalWorker {
46
+ port: number;
47
+ child: ChildProcess;
48
+ invoke(event: unknown): Promise<InvokeResult>;
49
+ stop(): void;
50
+ }
51
+
52
+ export async function startLocalWorker(
53
+ stage: LocalStage,
54
+ manifest: ResolvedManifest,
55
+ root: string,
56
+ opts: { watch?: boolean } = {},
57
+ ): Promise<LocalWorker> {
58
+ if (!manifest.worker) throw new Error('manifest has no worker');
59
+ const port = await freePort();
60
+ const env = localEnv(stage, manifest, {
61
+ PLATFORM_WORKER_ENTRY: resolve(root, manifest.worker.entry),
62
+ PLATFORM_SDK_WORKER: resolveFromApp(root, SDK_WORKER),
63
+ PLATFORM_WORKER_PORT: String(port),
64
+ PLATFORM_WORKER_TIMEOUT_MS: String(manifest.worker.timeoutSeconds * 1000),
65
+ PLATFORM_WORKER_FUNCTION:
66
+ stage.result.workerFunction?.name ?? names.workerFunction(manifest.name, stage.stage),
67
+ PLATFORM_LOG_PRETTY: '1',
68
+ });
69
+ const child = spawn(process.execPath, tsxArgs('worker-host', opts.watch !== false), {
70
+ cwd: root,
71
+ env,
72
+ detached: true,
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ });
75
+ await waitForPort(port, '127.0.0.1', 60_000);
76
+ return {
77
+ port,
78
+ child,
79
+ async invoke(event) {
80
+ // tsx watch restarts the host on edits; wait for it to come back.
81
+ await waitForPort(port, '127.0.0.1', 30_000);
82
+ const res = await fetch(`http://127.0.0.1:${port}/`, { method: 'POST', body: JSON.stringify(event) });
83
+ return (await res.json()) as InvokeResult;
84
+ },
85
+ stop() {
86
+ killTree(child);
87
+ },
88
+ };
89
+ }
90
+
91
+ export type DispatchLog = (
92
+ kind: 'queue' | 'event' | 'cron',
93
+ name: string,
94
+ outcome: 'ok' | 'error',
95
+ detail?: string,
96
+ ) => void;
97
+
98
+ export interface Dispatcher {
99
+ runCron(name: string): Promise<InvokeResult>;
100
+ stop(): void;
101
+ }
102
+
103
+ export async function startWorkerDispatch(
104
+ stage: LocalStage,
105
+ manifest: ResolvedManifest,
106
+ worker: Pick<LocalWorker, 'invoke'>,
107
+ log: DispatchLog = () => {},
108
+ ): Promise<Dispatcher> {
109
+ const cfg = emulatorClientConfig(stage.conn);
110
+ const sqs = new SQSClient(cfg);
111
+ const lambda = new LambdaClient(cfg);
112
+ const events = new EventBridgeClient(cfg);
113
+ const functionName = stage.result.workerFunction?.name;
114
+ let stopped = false;
115
+
116
+ // The emulator's own event source mappings would hand messages to its sandboxed runtime; turn them off.
117
+ if (functionName) {
118
+ const esms = await lambda
119
+ .send(new ListEventSourceMappingsCommand({ FunctionName: functionName }))
120
+ .catch(() => undefined);
121
+ for (const m of esms?.EventSourceMappings ?? []) {
122
+ if (m.UUID && m.State !== 'Disabled') {
123
+ await lambda
124
+ .send(new UpdateEventSourceMappingCommand({ UUID: m.UUID, Enabled: false }))
125
+ .catch(() => undefined);
126
+ }
127
+ }
128
+ }
129
+
130
+ const loops: Array<Promise<void>> = [];
131
+ for (const [name, q] of Object.entries(stage.bindings.queues)) {
132
+ const batchSize = manifest.queues[name]?.batchSize ?? 10;
133
+ loops.push(
134
+ pollQueue(
135
+ sqs,
136
+ q.url,
137
+ batchSize,
138
+ () => stopped,
139
+ async (messages) => {
140
+ const r = await worker.invoke(sqsEvent(messages, q.arn, stage.conn.region));
141
+ const failed = new Set(
142
+ r.ok
143
+ ? (
144
+ (r.result as { batchItemFailures?: Array<{ itemIdentifier: string }> } | null)
145
+ ?.batchItemFailures ?? []
146
+ ).map((f) => f.itemIdentifier)
147
+ : messages.map((m) => m.MessageId!),
148
+ );
149
+ log(
150
+ 'queue',
151
+ name,
152
+ failed.size ? 'error' : 'ok',
153
+ r.ok ? (failed.size ? `${failed.size} failed` : undefined) : r.error?.message,
154
+ );
155
+ return messages.filter((m) => !failed.has(m.MessageId!));
156
+ },
157
+ ),
158
+ );
159
+ }
160
+
161
+ if (functionName && stage.bindings.bus) {
162
+ const tapUrl = await tapEventRules(sqs, events, stage, functionName);
163
+ if (tapUrl) {
164
+ loops.push(
165
+ pollQueue(
166
+ sqs,
167
+ tapUrl,
168
+ 1,
169
+ () => stopped,
170
+ async (messages) => {
171
+ const done: Message[] = [];
172
+ for (const m of messages) {
173
+ const event = JSON.parse(m.Body ?? '{}') as { 'detail-type'?: string };
174
+ const attempts = Number(m.Attributes?.ApproximateReceiveCount ?? '1');
175
+ const r = await worker.invoke(event);
176
+ log('event', event['detail-type'] ?? '?', r.ok ? 'ok' : 'error', r.error?.message);
177
+ // Lambda async invokes retry twice, then drop: same here.
178
+ if (r.ok || attempts >= 3) done.push(m);
179
+ }
180
+ return done;
181
+ },
182
+ 5,
183
+ ),
184
+ );
185
+ }
186
+ }
187
+
188
+ return {
189
+ async runCron(name) {
190
+ const r = await worker.invoke({ __platform: 'cron', name });
191
+ log('cron', name, r.ok ? 'ok' : 'error', r.error?.message);
192
+ return r;
193
+ },
194
+ stop() {
195
+ stopped = true;
196
+ sqs.destroy();
197
+ lambda.destroy();
198
+ events.destroy();
199
+ },
200
+ };
201
+ }
202
+
203
+ /** Lambda's SQS event shape. */
204
+ export function sqsEvent(messages: Message[], queueArn: string, region: string) {
205
+ return {
206
+ Records: messages.map((m) => ({
207
+ messageId: m.MessageId,
208
+ receiptHandle: m.ReceiptHandle,
209
+ body: m.Body ?? '',
210
+ attributes: m.Attributes ?? {},
211
+ messageAttributes: Object.fromEntries(
212
+ Object.entries(m.MessageAttributes ?? {}).map(([k, v]) => [
213
+ k,
214
+ {
215
+ stringValue: v.StringValue,
216
+ binaryValue: v.BinaryValue,
217
+ dataType: v.DataType,
218
+ stringListValues: [],
219
+ binaryListValues: [],
220
+ },
221
+ ]),
222
+ ),
223
+ md5OfBody: m.MD5OfBody,
224
+ eventSource: 'aws:sqs',
225
+ eventSourceARN: queueArn,
226
+ awsRegion: region,
227
+ })),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Re-point the stage's EventBridge rules from the (sandboxed) emulator Lambda
233
+ * to a tap queue the dispatcher polls. Returns the tap queue URL.
234
+ */
235
+ async function tapEventRules(
236
+ sqs: SQSClient,
237
+ events: EventBridgeClient,
238
+ stage: LocalStage,
239
+ functionName: string,
240
+ ): Promise<string | null> {
241
+ const bus = stage.bindings.bus!.name;
242
+ const rules =
243
+ (await events.send(new ListRulesCommand({ EventBusName: bus })).catch(() => undefined))?.Rules ?? [];
244
+ if (rules.length === 0) return null;
245
+ const tapName = `${names.base(stage.bindings.app, stage.stage)}--plat-dev-events`;
246
+ const tapUrl = (await sqs.send(new CreateQueueCommand({ QueueName: tapName }))).QueueUrl!;
247
+ const tapArn = (
248
+ await sqs.send(new GetQueueAttributesCommand({ QueueUrl: tapUrl, AttributeNames: ['QueueArn'] }))
249
+ ).Attributes!.QueueArn!;
250
+ await sqs.send(
251
+ new SetQueueAttributesCommand({
252
+ QueueUrl: tapUrl,
253
+ Attributes: {
254
+ Policy: JSON.stringify({
255
+ Version: '2012-10-17',
256
+ Statement: [
257
+ {
258
+ Effect: 'Allow',
259
+ Principal: { Service: 'events.amazonaws.com' },
260
+ Action: 'sqs:SendMessage',
261
+ Resource: tapArn,
262
+ },
263
+ ],
264
+ }),
265
+ },
266
+ }),
267
+ );
268
+ for (const rule of rules) {
269
+ if (!rule.Name) continue;
270
+ const targets =
271
+ (await events.send(new ListTargetsByRuleCommand({ Rule: rule.Name, EventBusName: bus }))).Targets ?? [];
272
+ const lambdaTargets = targets.filter((t) => t.Arn?.includes(`:function:${functionName}`));
273
+ if (lambdaTargets.length === 0) continue;
274
+ await events.send(
275
+ new RemoveTargetsCommand({ Rule: rule.Name, EventBusName: bus, Ids: lambdaTargets.map((t) => t.Id!) }),
276
+ );
277
+ await events.send(
278
+ new PutTargetsCommand({
279
+ Rule: rule.Name,
280
+ EventBusName: bus,
281
+ Targets: [{ Id: 'plat-dev-tap', Arn: tapArn }],
282
+ }),
283
+ );
284
+ }
285
+ return tapUrl;
286
+ }
287
+
288
+ /** Long-poll a queue; `handle` returns the messages to delete. */
289
+ async function pollQueue(
290
+ sqs: SQSClient,
291
+ url: string,
292
+ batchSize: number,
293
+ stopped: () => boolean,
294
+ handle: (messages: Message[]) => Promise<Message[]>,
295
+ waitSeconds = 5,
296
+ ): Promise<void> {
297
+ while (!stopped()) {
298
+ let messages: Message[] = [];
299
+ try {
300
+ messages =
301
+ (
302
+ await sqs.send(
303
+ new ReceiveMessageCommand({
304
+ QueueUrl: url,
305
+ MaxNumberOfMessages: batchSize,
306
+ WaitTimeSeconds: waitSeconds,
307
+ MessageSystemAttributeNames: ['All'],
308
+ MessageAttributeNames: ['All'],
309
+ }),
310
+ )
311
+ ).Messages ?? [];
312
+ } catch {
313
+ if (stopped()) return;
314
+ await new Promise((r) => setTimeout(r, 1_000));
315
+ continue;
316
+ }
317
+ if (messages.length === 0) continue;
318
+ try {
319
+ const done = await handle(messages);
320
+ if (done.length) {
321
+ await sqs.send(
322
+ new DeleteMessageBatchCommand({
323
+ QueueUrl: url,
324
+ Entries: done.map((m, i) => ({ Id: String(i), ReceiptHandle: m.ReceiptHandle! })),
325
+ }),
326
+ );
327
+ }
328
+ } catch {
329
+ // Leave the messages; they become visible again after the visibility timeout.
330
+ }
331
+ }
332
+ }
package/src/github.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { appendFileSync, readFileSync } from 'node:fs';
3
+ import { PROD_STAGE, previewStage, validateStageName } from '@ericminassian/platform-core';
4
+ import { CliError, EXIT } from './util/output.ts';
5
+
6
+ /** GitHub Actions environment, read from `process.env` (injectable for tests). */
7
+ export type Env = Record<string, string | undefined>;
8
+
9
+ export function isGitHubActions(env: Env = process.env): boolean {
10
+ return env.GITHUB_ACTIONS === 'true';
11
+ }
12
+
13
+ interface GitHubEvent {
14
+ number?: number;
15
+ pull_request?: { number?: number; head?: { sha?: string; ref?: string }; title?: string };
16
+ head_commit?: { message?: string };
17
+ repository?: { default_branch?: string };
18
+ }
19
+
20
+ export function readEvent(env: Env = process.env): GitHubEvent {
21
+ if (!env.GITHUB_EVENT_PATH) return {};
22
+ try {
23
+ return JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) as GitHubEvent;
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ /**
30
+ * The stage a CI run deploys to: `pr-<n>` for pull requests, `prod` for pushes
31
+ * (or manual runs) on the default branch. `explicit` (from `--stage`) wins.
32
+ */
33
+ export function deriveStage(explicit: string | undefined, env: Env = process.env): string {
34
+ if (explicit) {
35
+ const err = validateStageName(explicit);
36
+ if (err) throw new CliError(err, { exitCode: EXIT.usage });
37
+ return explicit;
38
+ }
39
+ if (!isGitHubActions(env)) {
40
+ throw new CliError('no stage given', {
41
+ hint: 'pass --stage <name> (CI derives it: pull requests → pr-<n>, main → prod)',
42
+ exitCode: EXIT.usage,
43
+ });
44
+ }
45
+ const event = readEvent(env);
46
+ const name = env.GITHUB_EVENT_NAME;
47
+ if (name === 'pull_request' || name === 'pull_request_target') {
48
+ const n = event.pull_request?.number ?? event.number;
49
+ if (!n) throw new CliError('pull_request event without a PR number', { exitCode: EXIT.usage });
50
+ return previewStage(n);
51
+ }
52
+ const defaultBranch = event.repository?.default_branch ?? 'main';
53
+ if (env.GITHUB_REF === `refs/heads/${defaultBranch}`) return PROD_STAGE;
54
+ throw new CliError(
55
+ `can't derive a stage for ${name ?? 'this event'} on ${env.GITHUB_REF ?? 'unknown ref'}`,
56
+ {
57
+ hint: 'pass --stage explicitly',
58
+ exitCode: EXIT.usage,
59
+ },
60
+ );
61
+ }
62
+
63
+ /** Commit metadata for the deployment record. */
64
+ export function gitInfo(
65
+ cwd: string,
66
+ env: Env = process.env,
67
+ ): { commit?: string; ref?: string; message?: string } {
68
+ if (isGitHubActions(env)) {
69
+ const event = readEvent(env);
70
+ return {
71
+ commit: event.pull_request?.head?.sha ?? env.GITHUB_SHA,
72
+ ref: event.pull_request?.head?.ref ? `refs/heads/${event.pull_request.head.ref}` : env.GITHUB_REF,
73
+ message: (event.pull_request?.title ?? event.head_commit?.message)?.split('\n')[0],
74
+ };
75
+ }
76
+ const git = (...args: string[]) => {
77
+ try {
78
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ };
83
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD');
84
+ return {
85
+ commit: git('rev-parse', 'HEAD'),
86
+ ref: branch ? `refs/heads/${branch}` : undefined,
87
+ message: git('log', '-1', '--format=%s'),
88
+ };
89
+ }
90
+
91
+ /** GitHub Actions OIDC token for `audience` (requires `permissions: id-token: write`). */
92
+ export async function fetchOidcToken(audience: string, env: Env = process.env): Promise<string> {
93
+ const url = env.ACTIONS_ID_TOKEN_REQUEST_URL;
94
+ const token = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
95
+ if (!url || !token) {
96
+ throw new CliError('GitHub Actions OIDC is not available', {
97
+ hint: 'add `permissions: { id-token: write }` to the workflow',
98
+ exitCode: EXIT.auth,
99
+ });
100
+ }
101
+ const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}audience=${encodeURIComponent(audience)}`, {
102
+ headers: { authorization: `bearer ${token}`, accept: 'application/json' },
103
+ });
104
+ if (!res.ok) throw new CliError(`OIDC token request failed: HTTP ${res.status}`, { exitCode: EXIT.auth });
105
+ const body = (await res.json()) as { value?: string };
106
+ if (!body.value) throw new CliError('OIDC token response had no value', { exitCode: EXIT.auth });
107
+ return body.value;
108
+ }
109
+
110
+ export function canUseOidc(env: Env = process.env): boolean {
111
+ return isGitHubActions(env) && !!env.ACTIONS_ID_TOKEN_REQUEST_URL && !!env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
112
+ }
113
+
114
+ /** `key=value` into `$GITHUB_OUTPUT` (no-op outside Actions). */
115
+ export function setOutput(key: string, value: string, env: Env = process.env): void {
116
+ if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
117
+ }
118
+
119
+ export function appendStepSummary(markdown: string, env: Env = process.env): void {
120
+ if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `${markdown}\n`);
121
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @ericminassian/plat — programmatic API of the CLI. The foundation deploy
3
+ * and tests import `buildApp`; everything else is for the `plat` binary.
4
+ */
5
+ export { ApiClient, ApiError, apiBaseUrl } from './api.ts';
6
+ export { type BuildOptions, type BuildResult, buildApp, bundleWorker, validateForBuild } from './build.ts';
7
+ export { configDir, credentialsPath, loadCredentials, type StoredCredentials } from './credentials.ts';
8
+ export {
9
+ type DeployOptions,
10
+ type DeployResult,
11
+ deploy,
12
+ presignedPostForm,
13
+ waitForDeployment,
14
+ } from './deploy.ts';
15
+ export { type LocalStage, localEnv, startLocalStage } from './dev/local-stage.ts';
16
+ export { deriveStage, fetchOidcToken, gitInfo, isGitHubActions } from './github.ts';
17
+ export { type InitOptions, type InitResult, initApp, TEMPLATE_APP_NAME } from './init.ts';
18
+ export { createProgram, main } from './main.ts';
19
+ export { findProjectRoot, loadProject, type Project } from './project.ts';
20
+ export { CliError, EXIT } from './util/output.ts';
package/src/init.ts ADDED
@@ -0,0 +1,136 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
11
+ import { APP_CONFIG_FILE, validateAppName } from '@ericminassian/platform-core';
12
+ import { cliPackageRoot, cliVersion } from './project.ts';
13
+ import { CliError, EXIT } from './util/output.ts';
14
+
15
+ /** The app name used throughout `templates/app`; replaced on `plat init`. */
16
+ export const TEMPLATE_APP_NAME = 'template-app';
17
+ export const TEMPLATE_PACKAGE_NAME = 'platform-app-template';
18
+ /** Written into new apps so CI's corepack uses the same pnpm as the platform. */
19
+ export const PACKAGE_MANAGER = 'pnpm@12.11.2';
20
+
21
+ const SKIP = new Set(['node_modules', '.plat', 'dist', 'coverage', '.turbo']);
22
+ const TEXT_EXT = new Set([
23
+ '.ts',
24
+ '.tsx',
25
+ '.js',
26
+ '.mjs',
27
+ '.cjs',
28
+ '.json',
29
+ '.md',
30
+ '.html',
31
+ '.css',
32
+ '.yml',
33
+ '.yaml',
34
+ '.txt',
35
+ '.gitignore',
36
+ '',
37
+ ]);
38
+
39
+ /** Published CLI: `template/` inside the package. In this repo: `templates/app`. */
40
+ export function templateDir(): string {
41
+ const packaged = join(cliPackageRoot(), 'template');
42
+ if (existsSync(join(packaged, APP_CONFIG_FILE))) return packaged;
43
+ const repo = resolve(cliPackageRoot(), '..', '..', 'templates', 'app');
44
+ if (existsSync(join(repo, APP_CONFIG_FILE))) return repo;
45
+ throw new CliError('app template not found (broken CLI install?)');
46
+ }
47
+
48
+ export interface InitOptions {
49
+ dir: string;
50
+ name?: string;
51
+ force?: boolean;
52
+ /** Version range written for platform packages. Default `^<cli version>`. */
53
+ versionRange?: string;
54
+ template?: string;
55
+ }
56
+
57
+ export interface InitResult {
58
+ dir: string;
59
+ name: string;
60
+ files: string[];
61
+ }
62
+
63
+ export function initApp(opts: InitOptions): InitResult {
64
+ const dir = resolve(opts.dir);
65
+ const name = opts.name ?? basename(dir);
66
+ const err = validateAppName(name);
67
+ if (err) throw new CliError(`invalid app name: ${err}`, { exitCode: EXIT.usage });
68
+ if (existsSync(dir) && readdirSync(dir).filter((f) => f !== '.git').length > 0 && !opts.force) {
69
+ throw new CliError(`${dir} is not empty`, {
70
+ hint: 'choose an empty directory or pass --force',
71
+ exitCode: EXIT.usage,
72
+ });
73
+ }
74
+ const src = opts.template ?? templateDir();
75
+ const range = opts.versionRange ?? `^${cliVersion()}`;
76
+ const files = copyTree(src, dir, (rel, content) =>
77
+ rel === 'package.json' ? rewritePackageJson(content, name, range) : replaceAppName(content, name),
78
+ );
79
+ writeFileSync(join(dir, 'pnpm-workspace.yaml'), PNPM_WORKSPACE);
80
+ files.push('pnpm-workspace.yaml');
81
+ return { dir, name, files: files.sort() };
82
+ }
83
+
84
+ /**
85
+ * pnpm 10+ refuses to install (ERR_PNPM_IGNORED_BUILDS) until a dependency's
86
+ * install script is allowed, and esbuild (used by `plat build`) has one. It's
87
+ * written here rather than shipped in templates/app: a pnpm-workspace.yaml
88
+ * there would make that folder its own workspace inside this monorepo.
89
+ */
90
+ export const PNPM_WORKSPACE = `# esbuild (used by \`plat build\`) installs its native binary with a build script.
91
+ allowBuilds:
92
+ esbuild: true
93
+ `;
94
+
95
+ export function replaceAppName(content: string, name: string): string {
96
+ return content.split(TEMPLATE_APP_NAME).join(name);
97
+ }
98
+
99
+ export function rewritePackageJson(content: string, name: string, range: string): string {
100
+ const pkg = JSON.parse(content) as Record<string, unknown>;
101
+ pkg.name = name;
102
+ pkg.packageManager ??= PACKAGE_MANAGER;
103
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
104
+ const deps = pkg[field] as Record<string, string> | undefined;
105
+ if (!deps) continue;
106
+ for (const [dep, v] of Object.entries(deps)) if (v.startsWith('workspace:')) deps[dep] = range;
107
+ }
108
+ return `${JSON.stringify(pkg, null, 2)}\n`;
109
+ }
110
+
111
+ /** Copies `src` to `dest`, transforming text files; returns the written paths (relative). */
112
+ function copyTree(src: string, dest: string, transform: (rel: string, content: string) => string): string[] {
113
+ const written: string[] = [];
114
+ const walk = (from: string) => {
115
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
116
+ if (SKIP.has(entry.name)) continue;
117
+ const abs = join(from, entry.name);
118
+ const rel = relative(src, abs).split('\\').join('/');
119
+ if (rel === 'web/dist') continue;
120
+ if (entry.isDirectory()) {
121
+ walk(abs);
122
+ continue;
123
+ }
124
+ // npm drops `.gitignore` from packages, so the published template stores `_gitignore`.
125
+ const outRel = rel.replace(/(^|\/)_gitignore$/, '$1.gitignore');
126
+ const target = join(dest, outRel);
127
+ mkdirSync(dirname(target), { recursive: true });
128
+ const isText = TEXT_EXT.has(extname(entry.name)) && statSync(abs).size < 1_000_000;
129
+ if (isText) writeFileSync(target, transform(outRel, readFileSync(abs, 'utf8')));
130
+ else copyFileSync(abs, target);
131
+ written.push(outRel);
132
+ }
133
+ };
134
+ walk(src);
135
+ return written;
136
+ }
package/src/main.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { Command } from 'commander';
2
+ import { registerAccount } from './commands/account.ts';
3
+ import { registerApps } from './commands/apps.ts';
4
+ import { registerLifecycle } from './commands/lifecycle.ts';
5
+ import { registerManage } from './commands/manage.ts';
6
+ import { registerObserve } from './commands/observe.ts';
7
+ import { cliVersion } from './project.ts';
8
+
9
+ export function createProgram(): Command {
10
+ const program = new Command('plat')
11
+ .description(
12
+ 'The platform CLI: create, run locally (AWS Emulator), deploy and observe apps.\nEvery command accepts --json for machine-readable output.',
13
+ )
14
+ .version(cliVersion(), '-v, --version')
15
+ .showHelpAfterError('(run with --help for usage)')
16
+ .configureHelp({ sortSubcommands: false });
17
+
18
+ registerLifecycle(program);
19
+ registerApps(program);
20
+ registerAccount(program);
21
+ registerObserve(program);
22
+ registerManage(program);
23
+ return program;
24
+ }
25
+
26
+ export async function main(argv: string[]): Promise<void> {
27
+ await createProgram().parseAsync(argv);
28
+ }