@everystack/cli 0.2.37 → 0.3.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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
+ *
4
+ * db:pull --stage <name> [--schema public] [--out models/index.ts]
5
+ *
6
+ * The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
7
+ * database. It introspects the deployed schema through the read-only ops Lambda `db:query`
8
+ * action and renders the TypeScript a Model is written in — a starting point you review and
9
+ * refine (it flags, as inline comments, anything the field builder can't yet express).
10
+ *
11
+ * The rendered source is the artifact: it goes to stdout (so `db:pull > models.ts` works) or
12
+ * to `--out`. Every progress/diagnostic line goes to stderr, so the stdout pipe stays pure
13
+ * source. The renderer (model-render.ts) is pure; this shell is the IO.
14
+ */
15
+
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { introspectSchema } from '../schema-introspect.js';
19
+ import { renderModelSource } from '../model-render.js';
20
+ import type { QueryRunner } from '../authz-contract.js';
21
+ import { resolveConfig, opsFunction } from '../config.js';
22
+ import { invokeAction } from '../aws.js';
23
+ import { fail } from '../output.js';
24
+
25
+ // Progress → stderr, so stdout carries only the rendered source (this command is codegen,
26
+ // not a report — the shared output helpers write to stdout, which would corrupt a redirect).
27
+ const note = (m: string) => console.error(` > ${m}`);
28
+ const ok = (m: string) => console.error(` ✓ ${m}`);
29
+ const detail = (m: string) => console.error(` ${m}`);
30
+ const caution = (m: string) => console.error(` ! ${m}`);
31
+
32
+ /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
33
+ function lambdaRunner(region: string, fn: string): QueryRunner {
34
+ return async (sql: string) => {
35
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
36
+ if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
37
+ return result?.rows ?? [];
38
+ };
39
+ }
40
+
41
+ export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
42
+ const schema = flags.schema || 'public';
43
+
44
+ let current;
45
+ try {
46
+ note('Resolving deployed config...');
47
+ const config = await resolveConfig(flags.stage);
48
+ detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
49
+ note(`Introspecting live database (schema: ${schema})...`);
50
+ current = await introspectSchema(lambdaRunner(config.region, opsFunction(config)));
51
+ } catch (err: any) {
52
+ fail(err.message);
53
+ detail('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
54
+ process.exit(1);
55
+ }
56
+
57
+ const pulled = current.tables.filter((t) => t.table.startsWith(`${schema}.`));
58
+ if (pulled.length === 0) {
59
+ fail(`No tables found in schema "${schema}".`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const source = renderModelSource(current, { schema });
64
+
65
+ if (flags.out) {
66
+ const outPath = path.resolve(flags.out);
67
+ try {
68
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
69
+ await fs.writeFile(outPath, source, 'utf8');
70
+ } catch (err: any) {
71
+ fail(`Could not write ${flags.out}: ${err.message}`);
72
+ process.exit(1);
73
+ }
74
+ ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
75
+ } else {
76
+ process.stdout.write(source);
77
+ ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
78
+ }
79
+
80
+ const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →)/g) ?? []).length;
81
+ if (flagged) caution(`${flagged} inline comment(s) flag things to review (unmapped types, checks, cross-schema FKs).`);
82
+ process.exit(0);
83
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * db:snapshot / db:snapshots — RDS *physical* snapshots, the thin `aws rds` control-plane wrapper.
3
+ *
4
+ * Unlike db:backup (logical pg_dump, runs in the ops Lambda), these are plain RDS management calls
5
+ * made directly from the CLI with the operator's IAM — no Lambda, no VPC, no pg_dump layer. They
6
+ * only apply when the DB is an RDS instance; for any other Postgres, use db:backup. See
7
+ * docs/plans/db-backup-restore.md.
8
+ */
9
+
10
+ import { resolveConfig, type CliConfig } from '../config.js';
11
+ import { createRdsSnapshot, describeRdsSnapshots, type RdsSnapshotSummary } from '../aws.js';
12
+ import { utcStamp } from '../backup.js';
13
+ import { rdsSnapshotIdentifier } from '../rds-snapshot.js';
14
+ import { step, success, fail, info } from '../output.js';
15
+
16
+ /**
17
+ * Resolve the RDS DB instance identifier: explicit `--instance` wins, else the deployed
18
+ * `databaseInstanceId` stack output. A missing or `placeholder` value means the DB isn't RDS (or
19
+ * the stage is in dev mode) — fail with a pointer to db:backup rather than calling AWS with junk.
20
+ */
21
+ function resolveInstanceId(config: CliConfig, flags: Record<string, string>): string {
22
+ const id = flags.instance ?? config.databaseInstanceId;
23
+ if (!id || id === 'placeholder') {
24
+ fail(
25
+ 'No RDS instance id available. db:snapshot needs an RDS instance.\n' +
26
+ ' - If your DB is RDS: redeploy so the `databaseInstanceId` output is present, or pass --instance <id>.\n' +
27
+ ' - If your DB is not RDS (Aurora, a container, self-hosted): use `everystack db:backup` for a portable logical backup.',
28
+ );
29
+ process.exit(1);
30
+ }
31
+ return id;
32
+ }
33
+
34
+ /** Only an authorization failure warrants the IAM hint; other RDS errors (state, quota) don't. */
35
+ function isAccessDenied(err: any): boolean {
36
+ const name = String(err?.name ?? '');
37
+ return /AccessDenied|Unauthorized|NotAuthorized/i.test(name);
38
+ }
39
+
40
+ function fmtSize(gib?: number): string {
41
+ return gib == null ? '-' : `${gib} GiB`;
42
+ }
43
+
44
+ function fmtDate(d?: Date): string {
45
+ return d ? d.toISOString().replace('.000Z', 'Z') : '(creating)';
46
+ }
47
+
48
+ export async function dbSnapshotCommand(flags: Record<string, string>): Promise<void> {
49
+ step('Resolving deployed config...');
50
+ let config: CliConfig;
51
+ try {
52
+ config = await resolveConfig(flags.stage);
53
+ } catch (err: any) {
54
+ fail(err.message);
55
+ process.exit(1);
56
+ }
57
+
58
+ const instanceId = resolveInstanceId(config, flags);
59
+ const snapshotId = rdsSnapshotIdentifier(flags.stage ?? 'manual', utcStamp(new Date()));
60
+
61
+ info(`Region: ${config.region}, Instance: ${instanceId}`);
62
+ step(`Creating RDS snapshot ${snapshotId}...`);
63
+
64
+ try {
65
+ const snap = await createRdsSnapshot(config.region, instanceId, snapshotId);
66
+ success(`Snapshot ${snap.identifier} (${snap.status}) — physical RDS image of ${instanceId}.`);
67
+ info('It becomes restorable once status is "available". Track it with `everystack db:snapshots`.');
68
+ } catch (err: any) {
69
+ fail(`Snapshot failed: ${err.message}`);
70
+ if (isAccessDenied(err)) {
71
+ info('Ensure your IAM identity has rds:CreateDBSnapshot on this instance.');
72
+ } else if (/not currently in the available state|InvalidDBInstanceState/i.test(String(err?.message ?? err?.name))) {
73
+ info('The instance is busy (another snapshot may be in progress). Wait for it to finish, then retry.');
74
+ }
75
+ process.exit(1);
76
+ }
77
+ }
78
+
79
+ export async function dbSnapshotsCommand(flags: Record<string, string>): Promise<void> {
80
+ step('Resolving deployed config...');
81
+ let config: CliConfig;
82
+ try {
83
+ config = await resolveConfig(flags.stage);
84
+ } catch (err: any) {
85
+ fail(err.message);
86
+ process.exit(1);
87
+ }
88
+
89
+ const instanceId = resolveInstanceId(config, flags);
90
+ step(`Listing manual RDS snapshots for ${instanceId}...`);
91
+
92
+ let snaps: RdsSnapshotSummary[];
93
+ try {
94
+ snaps = await describeRdsSnapshots(config.region, instanceId);
95
+ } catch (err: any) {
96
+ fail(`List failed: ${err.message}`);
97
+ if (isAccessDenied(err)) {
98
+ info('Ensure your IAM identity has rds:DescribeDBSnapshots.');
99
+ }
100
+ process.exit(1);
101
+ }
102
+
103
+ if (snaps.length === 0) {
104
+ info('No manual snapshots. Create one with `everystack db:snapshot`.');
105
+ return;
106
+ }
107
+
108
+ for (const s of snaps) {
109
+ const enc = s.encrypted ? ' [encrypted]' : '';
110
+ info(`${s.identifier} ${s.status.padEnd(10)} ${fmtSize(s.sizeGiB).padEnd(8)} pg${s.engineVersion ?? '?'} ${fmtDate(s.created)}${enc}`);
111
+ }
112
+ success(`${snaps.length} snapshot${snaps.length === 1 ? '' : 's'}.`);
113
+ }
@@ -9,6 +9,34 @@ import { resolveConfig, opsFunction } from '../config.js';
9
9
  import { invokeAction } from '../aws.js';
10
10
  import { step, success, fail, info, warn } from '../output.js';
11
11
 
12
+ /** Secret-store keys that may hold the privileged (operator) connection URL,
13
+ * in precedence order. Names vary by deployment; --secret-name overrides. */
14
+ const ADMIN_URL_KEYS = ['ADMIN_DATABASE_URL', 'AdminDatabaseUrl', 'ADMIN_URL'];
15
+
16
+ /**
17
+ * Parse a postgres connection URL into psql's PG* environment variables.
18
+ *
19
+ * Passing the credential via env (not argv) keeps the password out of the
20
+ * process table and shell history. Pure and side-effect free — it never logs;
21
+ * callers must never log the result either (it contains PGPASSWORD).
22
+ */
23
+ export function pgEnvFromUrl(url: string): Record<string, string> {
24
+ const u = new URL(url);
25
+ if (!/^postgres(ql)?:$/.test(u.protocol)) {
26
+ throw new Error(`Not a postgres connection URL (protocol: ${u.protocol})`);
27
+ }
28
+ const env: Record<string, string> = {};
29
+ if (u.hostname) env.PGHOST = decodeURIComponent(u.hostname);
30
+ if (u.port) env.PGPORT = u.port;
31
+ if (u.username) env.PGUSER = decodeURIComponent(u.username);
32
+ if (u.password) env.PGPASSWORD = decodeURIComponent(u.password);
33
+ const database = u.pathname.replace(/^\//, '');
34
+ if (database) env.PGDATABASE = decodeURIComponent(database);
35
+ const sslmode = u.searchParams.get('sslmode');
36
+ if (sslmode) env.PGSSLMODE = sslmode;
37
+ return env;
38
+ }
39
+
12
40
  export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
13
41
  step('Resolving deployed config...');
14
42
  let config;
@@ -115,6 +143,130 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
115
143
  }
116
144
  }
117
145
 
146
+ export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
147
+ if (!flags.stage) {
148
+ fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
149
+ process.exit(1);
150
+ }
151
+
152
+ step('Resolving deployed config...');
153
+ let config;
154
+ try {
155
+ config = await resolveConfig(flags.stage);
156
+ } catch (err: any) {
157
+ fail(err.message);
158
+ process.exit(1);
159
+ }
160
+
161
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
162
+ info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
163
+ step('Provisioning roles...');
164
+
165
+ // Generate the login password and keep it in memory only — it is written straight into
166
+ // the secret store and is NEVER printed, logged, or returned to a human.
167
+ const { randomBytes } = await import('node:crypto');
168
+ const authPassword = randomBytes(24).toString('hex');
169
+
170
+ let result: any;
171
+ try {
172
+ result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
173
+ } catch (err: any) {
174
+ fail(`db:provision failed: ${err.message}`);
175
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
176
+ process.exit(1);
177
+ }
178
+ if (result?.error) {
179
+ fail(`db:provision failed: ${result.error}`);
180
+ process.exit(1);
181
+ }
182
+ success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
183
+
184
+ // Resolve host/port/db (not secret) to build the connection string in memory.
185
+ let conn: any;
186
+ try {
187
+ conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
188
+ } catch {
189
+ /* handled below */
190
+ }
191
+ if (!conn?.host) {
192
+ fail('Could not resolve the database host to build DATABASE_URL.');
193
+ info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
194
+ process.exit(1);
195
+ }
196
+ const url = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
197
+
198
+ // Write the secret directly to the SST secret store — never display it.
199
+ step('Writing the DATABASE_URL secret (not displayed)...');
200
+ try {
201
+ const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
202
+ const { parseAppName } = await import('../discover.js');
203
+ const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
204
+ const secrets = await loadSstSecrets(ctx);
205
+ secrets.DATABASE_URL = url;
206
+ await putSstSecrets(ctx, secrets);
207
+ } catch (err: any) {
208
+ fail(`Failed to write the DATABASE_URL secret: ${err.message}`);
209
+ info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
210
+ process.exit(1);
211
+ }
212
+
213
+ success(`DATABASE_URL secret set for stage "${flags.stage}" → role ${result.loginRole}. No credential was displayed.`);
214
+ console.log('');
215
+ info('Finish the wiring in sst.config.ts (one-time): declare `new sst.Secret("DatabaseUrl")`,');
216
+ info('link it to the Api function, and remove the raw Postgres component from the Api link');
217
+ info('(keep it on the ops/worker functions). Then redeploy.');
218
+ info(`Verify: everystack db:doctor --stage ${flags.stage}`);
219
+ }
220
+
221
+ export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
222
+ step('Resolving deployed config...');
223
+ let config;
224
+ try {
225
+ config = await resolveConfig(flags.stage);
226
+ } catch (err: any) {
227
+ fail(err.message);
228
+ process.exit(1);
229
+ }
230
+
231
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
232
+ step('Probing database connections (api + operator)...');
233
+
234
+ let report: any;
235
+ try {
236
+ report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
237
+ } catch (err: any) {
238
+ fail(`db:doctor failed: ${err.message}`);
239
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
240
+ process.exit(1);
241
+ }
242
+
243
+ if (report?.error) {
244
+ fail(`db:doctor failed: ${report.error}`);
245
+ process.exit(1);
246
+ }
247
+
248
+ console.log('');
249
+ info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
250
+ info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
251
+ console.log('');
252
+
253
+ for (const c of report.checks as Array<{ name: string; status: string; detail: string; remediation?: string }>) {
254
+ if (c.status === 'pass') success(`${c.name} — ${c.detail}`);
255
+ else if (c.status === 'warn') warn(`${c.name} — ${c.detail}`);
256
+ else fail(`${c.name} — ${c.detail}`);
257
+ if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
258
+ }
259
+
260
+ console.log('');
261
+ if (report.ok) {
262
+ success('db:doctor — database is least-privilege and RLS-subject');
263
+ process.exit(0);
264
+ } else {
265
+ fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
266
+ process.exit(1);
267
+ }
268
+ }
269
+
118
270
  export async function dbPsqlCommand(flags: Record<string, string>): Promise<void> {
119
271
  step('Resolving deployed config...');
120
272
  let config;
@@ -180,9 +332,67 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
180
332
  }
181
333
  }
182
334
 
183
- // Interactive mode: not supported for private RDS
184
- fail('Interactive psql mode not supported for private RDS instances.');
185
- info('Use -c flag to execute SQL queries via Lambda: everystack db:psql -c "SELECT * FROM users;"');
186
- info('The Lambda function executes queries from within the VPC where it can reach the database.');
187
- process.exit(1);
335
+ // Interactive mode break-glass admin psql.
336
+ //
337
+ // Authorization IS your AWS identity: resolving the admin URL requires reading
338
+ // the SST secret store (SSM passphrase + S3 blob + KMS decrypt). If your
339
+ // profile can't, you don't get a session — the permission is the policy, no
340
+ // confirmation prompt. CloudTrail records the decrypt, so the access is
341
+ // audited for free. The URL is resolved in-process and handed to psql via PG*
342
+ // env vars: it never touches your terminal, argv, clipboard, or psql history.
343
+ if (!flags.stage) {
344
+ fail('--stage is required for an interactive session. Example: everystack db:psql --stage production');
345
+ info('Or run a one-off query via Lambda: everystack db:psql -c "SELECT 1" --stage production');
346
+ process.exit(1);
347
+ }
348
+
349
+ step('Resolving the admin connection from the secret store...');
350
+ let secrets: Record<string, string>;
351
+ try {
352
+ const { loadSstSecrets } = await import('../utils/secrets.js');
353
+ const { parseAppName } = await import('../discover.js');
354
+ const appName = await parseAppName();
355
+ secrets = await loadSstSecrets({ appName, stage: flags.stage, region: config.region });
356
+ } catch (err: any) {
357
+ fail('Could not read the secret store — your AWS profile lacks secrets access.');
358
+ info('db:psql opens an ADMIN session; it requires SSM + S3 + KMS access to the SST state.');
359
+ info('If you should have it, check your IAM permissions; otherwise you should not be in psql.');
360
+ process.exit(1);
361
+ }
362
+
363
+ const keyName = flags['secret-name'] || ADMIN_URL_KEYS.find((k) => secrets[k]);
364
+ const adminUrl = keyName ? secrets[keyName] : undefined;
365
+ if (!adminUrl) {
366
+ fail(`No admin database URL found for stage "${flags.stage}".`);
367
+ // Names only — never values.
368
+ info(`Looked for: ${ADMIN_URL_KEYS.join(', ')}. Available secret names: ${Object.keys(secrets).join(', ') || '(none)'}.`);
369
+ info('Provision one with `everystack db:provision`, or point at an existing key with --secret-name <KEY>.');
370
+ process.exit(1);
371
+ }
372
+
373
+ let pgEnv: Record<string, string>;
374
+ try {
375
+ pgEnv = pgEnvFromUrl(adminUrl);
376
+ } catch {
377
+ fail(`The resolved ${keyName} is not a valid postgres connection URL.`);
378
+ process.exit(1);
379
+ }
380
+
381
+ // Confirm the target (host/user/db are infra identifiers, not the credential);
382
+ // the password is in PGPASSWORD and is never displayed.
383
+ success(
384
+ `Admin psql → ${flags.stage} as ${pgEnv.PGUSER ?? '?'}@${pgEnv.PGHOST ?? '?'}/${pgEnv.PGDATABASE ?? ''} (credential not displayed)`,
385
+ );
386
+
387
+ const { spawn } = await import('node:child_process');
388
+ const child = spawn('psql', [], { stdio: 'inherit', env: { ...process.env, ...pgEnv } });
389
+ child.on('error', (err: any) => {
390
+ if (err.code === 'ENOENT') {
391
+ fail('psql not found on PATH. Install the PostgreSQL client to use interactive mode.');
392
+ } else {
393
+ fail(`Failed to launch psql: ${err.message}`);
394
+ }
395
+ process.exit(1);
396
+ });
397
+ child.on('close', (code) => process.exit(code ?? 0));
188
398
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `everystack deploy` — deploy the stack's infrastructure.
3
+ *
4
+ * A thin wrapper over `sst deploy` so a consumer never has to type or know about SST: the
5
+ * framework owns the infra tool, the consumer just runs `everystack deploy --stage <name>`.
6
+ * Infra only — migrations (`db:migrate`) and OTA (`update`) stay explicit, the same split
7
+ * `sst deploy` itself has.
8
+ *
9
+ * The arg build is pure (unit-tested); the command streams sst's output through and exits
10
+ * with its code so CI sees a failed deploy.
11
+ */
12
+
13
+ import { spawn } from 'node:child_process';
14
+ import { step, success, fail, info } from '../output.js';
15
+
16
+ /** The `sst` argv `everystack deploy` runs. Pure, so the mapping is unit-tested. */
17
+ export function buildDeployArgs(flags: Record<string, string>): string[] {
18
+ const stage = flags.stage || 'dev';
19
+ return ['sst', 'deploy', '--stage', stage];
20
+ }
21
+
22
+ export async function deployCommand(flags: Record<string, string>): Promise<void> {
23
+ const stage = flags.stage || 'dev';
24
+ const args = buildDeployArgs(flags);
25
+
26
+ step(`Deploying stage "${stage}"...`);
27
+ await new Promise<void>((resolve) => {
28
+ // `npx` resolves the consumer's local sst (the same way the export step runs expo).
29
+ const child = spawn('npx', args, { stdio: 'inherit', env: { ...process.env, FORCE_COLOR: '1' } });
30
+ child.on('close', (code) => {
31
+ if (code === 0) {
32
+ resolve();
33
+ } else {
34
+ fail(`Deploy failed (exit ${code}).`);
35
+ process.exit(code ?? 1);
36
+ }
37
+ });
38
+ child.on('error', (err) => {
39
+ fail(`Could not run sst: ${err.message}`);
40
+ info('Install it as a dev dependency: `pnpm add -D sst`.');
41
+ process.exit(1);
42
+ });
43
+ });
44
+ success(`Deployed stage "${stage}".`);
45
+ info('Next: `everystack db:migrate --stage ' + stage + '` to apply migrations, `everystack update` to publish the app bundle.');
46
+ }
@@ -133,6 +133,62 @@ function resolveFunctionForOrigin(
133
133
  }
134
134
  }
135
135
 
136
+ /** One FilterLogEvents page (the subset of the AWS shape this command reads). */
137
+ interface FilterLogEventsPage {
138
+ events?: Array<{ eventId?: string; timestamp?: number; message?: string }>;
139
+ nextToken?: string;
140
+ }
141
+
142
+ /** Carries the tail window across polls so a re-query can't reprint events. */
143
+ export interface TailState {
144
+ /** Highest event timestamp printed so far. Only moves forward. */
145
+ lastTimestamp: number;
146
+ /** eventId → timestamp for events at the boundary millisecond — the only ones
147
+ * an inclusive-startTime re-query can return again. Pruned each drain so it
148
+ * never grows past one millisecond's worth of events. */
149
+ seen: Map<string, number>;
150
+ }
151
+
152
+ /**
153
+ * Drain one poll's worth of pages, printing only events not seen before and
154
+ * advancing the window forward.
155
+ *
156
+ * The loop bug this fixes: FilterLogEvents `startTime` is INCLUSIVE, so a poll
157
+ * with `startTime = lastTimestamp` re-returns every event sharing that newest
158
+ * millisecond. The old code advanced `lastTimestamp` only on a strictly-greater
159
+ * comparison and never deduped, so the boundary batch reprinted on every 2s poll
160
+ * forever. Here we dedup by `eventId` and advance to the max timestamp, then
161
+ * prune `seen` to just the boundary millisecond (the only ids that can recur).
162
+ *
163
+ * Exported for unit testing without the AWS SDK or the setTimeout poll loop.
164
+ */
165
+ export async function drainLogEvents(
166
+ send: (params: { startTime: number; nextToken?: string }) => Promise<FilterLogEventsPage>,
167
+ state: TailState,
168
+ emit: (line: string) => void,
169
+ ): Promise<void> {
170
+ let nextToken: string | undefined;
171
+ do {
172
+ const page = await send({ startTime: state.lastTimestamp, nextToken });
173
+ for (const event of page.events ?? []) {
174
+ const id = event.eventId;
175
+ const ts = event.timestamp ?? state.lastTimestamp;
176
+ if (id && state.seen.has(id)) continue; // already printed this one
177
+ const timestamp = event.timestamp ? new Date(event.timestamp).toISOString() : '';
178
+ emit(`[${timestamp}] ${(event.message || '').trimEnd()}`);
179
+ if (id) state.seen.set(id, ts);
180
+ if (ts > state.lastTimestamp) state.lastTimestamp = ts;
181
+ }
182
+ nextToken = page.nextToken;
183
+ } while (nextToken);
184
+
185
+ // Only events at exactly lastTimestamp can be returned again (startTime is
186
+ // inclusive); drop everything older so `seen` stays bounded.
187
+ for (const [id, ts] of state.seen) {
188
+ if (ts < state.lastTimestamp) state.seen.delete(id);
189
+ }
190
+ }
191
+
136
192
  export async function logsTailCommand(flags: Record<string, string>): Promise<void> {
137
193
  step('Resolving deployed config...');
138
194
  let config;
@@ -174,42 +230,20 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
174
230
 
175
231
  success(`Streaming logs from ${logGroupName}...\n`);
176
232
 
177
- // Poll for new logs every 2 seconds
178
- let nextToken: string | undefined;
179
- let lastTimestamp = startTime;
233
+ // Poll for new logs every 2 seconds. State carries the window + dedup set
234
+ // across polls see drainLogEvents for why both are required.
235
+ const state: TailState = { lastTimestamp: startTime, seen: new Map() };
236
+ const send = (params: { startTime: number; nextToken?: string }) =>
237
+ client.send(new FilterLogEventsCommand({
238
+ logGroupName,
239
+ startTime: params.startTime,
240
+ filterPattern,
241
+ ...(params.nextToken ? { nextToken: params.nextToken } : {}),
242
+ }));
180
243
 
181
244
  const poll = async () => {
182
245
  try {
183
- const params: any = {
184
- logGroupName,
185
- startTime: lastTimestamp,
186
- filterPattern,
187
- };
188
-
189
- if (nextToken) {
190
- params.nextToken = nextToken;
191
- }
192
-
193
- const response = await client.send(new FilterLogEventsCommand(params));
194
-
195
- if (response.events && response.events.length > 0) {
196
- for (const event of response.events) {
197
- if (event.timestamp && event.timestamp > lastTimestamp) {
198
- lastTimestamp = event.timestamp;
199
- }
200
-
201
- const timestamp = event.timestamp
202
- ? new Date(event.timestamp).toISOString()
203
- : '';
204
- const message = event.message || '';
205
-
206
- console.log(`[${timestamp}] ${message.trimEnd()}`);
207
- }
208
- }
209
-
210
- nextToken = response.nextToken;
211
-
212
- // Continue polling
246
+ await drainLogEvents(send, state, (line) => console.log(line));
213
247
  setTimeout(poll, 2000);
214
248
  } catch (err: any) {
215
249
  if (err.name === 'ResourceNotFoundException') {