@everystack/cli 0.3.28 → 0.3.31

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.
@@ -423,6 +423,22 @@ export async function invalidateCacheByFunctionName(staleName: string): Promise<
423
423
  }
424
424
  }
425
425
 
426
+ /**
427
+ * Delete the per-stage config cache for one stage, forcing the next stage command to
428
+ * re-discover live. Called after `everystack deploy` (a deploy can add a function the old
429
+ * cache never saw — the Ops function is the canonical case: its name stays absent from a
430
+ * config discovered before it existed, and the 24h TTL keeps that stale config valid, so
431
+ * db ops misroute to the Api function) and by `--refresh`. Best-effort; only touches the
432
+ * `outputs.{stage}.json` cache, never the deploy-written `outputs.json`.
433
+ */
434
+ export async function invalidateCachedConfig(stage: string): Promise<void> {
435
+ try {
436
+ await fs.unlink(cachePath(stage));
437
+ } catch {
438
+ // No cache for this stage — nothing to invalidate.
439
+ }
440
+ }
441
+
426
442
  export async function setCachedConfig(stage: string, config: DiscoveredConfig): Promise<void> {
427
443
  const data: CachedOutputs = {
428
444
  _cachedAt: Date.now(),
package/src/cli/index.ts CHANGED
@@ -20,6 +20,7 @@ import { dbApplyCommand } from './commands/db-apply.js';
20
20
  import { dbCheckCommand } from './commands/db-check.js';
21
21
  import { dbApproversCommand } from './commands/db-approvers.js';
22
22
  import { dbBackfillCommand } from './commands/db-backfill.js';
23
+ import { pipelineRunCommand, pipelineListCommand } from './commands/pipeline-run.js';
23
24
  import { dbTemplateRefreshCommand, dbBranchCommand } from './commands/db-branch.js';
24
25
  import { dbForkCommand } from './commands/db-fork.js';
25
26
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
@@ -114,6 +115,14 @@ async function main() {
114
115
  process.exit(0);
115
116
  }
116
117
 
118
+ // `--refresh` forces re-discovery: drop the stale per-stage cache before anything reads it
119
+ // (loadSstOutputs and resolveConfig both consult it). Use after a deploy the CLI didn't run,
120
+ // or whenever a stage command resolves to the wrong function.
121
+ if (flags.refresh && flags.stage) {
122
+ const { invalidateCachedConfig } = await import('./discover.js');
123
+ await invalidateCachedConfig(flags.stage);
124
+ }
125
+
117
126
  // Auto-detect deployed URL from SST outputs
118
127
  await loadSstOutputs(flags.stage);
119
128
 
@@ -210,6 +219,12 @@ async function main() {
210
219
  case 'db:backfill':
211
220
  await dbBackfillCommand(flags);
212
221
  break;
222
+ case 'pipeline:run':
223
+ await pipelineRunCommand(flags);
224
+ break;
225
+ case 'pipeline:list':
226
+ await pipelineListCommand(flags);
227
+ break;
213
228
  case 'db:template:refresh':
214
229
  await dbTemplateRefreshCommand(flags);
215
230
  break;
@@ -342,14 +357,16 @@ Usage:
342
357
  everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise
343
358
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
344
359
  everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
345
- everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only) and records provenance + schema_log. Hand-edits are drift (never overwritten silently); first contact with existing objects wants --baseline, once.
360
+ everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only, atomic — DDL + provenance in one transaction) and records provenance + schema_log. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive.
346
361
  everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
347
362
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
348
363
  everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
349
364
  everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + a snapshot (automatic via db:backup with --stage, else --snapshot-ref) + the stage's approver set when declared (STS identity-verified). Every refusal is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there; direct connection required
350
- everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses) and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
365
+ everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
351
366
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
352
367
  everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
368
+ everystack pipeline:run [--stage <name> | --database-url <url>] [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] [--resume [--run-id <id>]] [--dry-run] [--continue-on-error] [--json] Reproduce data from committed source: run the pipeline's stages in topological order, each idempotent and inside its own transaction, recorded in everystack.pipeline_log. Lanes: --rebuild (automated+frozen) / --curate (curated) / neither (all in order). --resume picks up the latest run, skipping applied stages. With --stage the orchestrator runs credential-free IN the ops Lambda (no URL held; heavy stages refused — run those local); --database-url is local-direct and unbounded
369
+ everystack pipeline:list [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] Show the pipeline's stages in run order (topological) with their lane and dependency edges — no execution, no database
353
370
  everystack db:template:refresh [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--seed "<cmd>"] [--no-seed] (Re)build the dev template <base>_tpl FROM THE DECLARED STATE (both layers, fingerprint MATCH bar) + seed-as-code (the app's db:seed script, run with DATABASE_URL pointed at the template; --seed overrides). All or nothing: a failed build/seed leaves NO template. Never a data copy
354
371
  everystack db:branch [--list | --drop --confirm | --prune --confirm] [--database-url <url>] Mint (or find) the current git branch's database from the template (CREATE DATABASE … TEMPLATE — schema, authz, derived layer, and seed rows inherited), print its DATABASE_URL, then db:sync evolves it with the checkout. --list maps every branch DB to its branch; --prune drops the ones whose branch is gone (never guesses: unknown mappings are kept)
355
372
  everystack db:fork --from-stage <src> --stage <target> --confirm [--backup <id>] Fork one DEPLOYED stage's database into another: back up the source (or reuse --backup <id>), presign the dump (the operator's credentials ARE the cross-stage authorization; expires in 1h), restore into the target via its ops Lambda. Production is never a target (that's db:restore); forking FROM production warns about PII; the branch's schema edge then lands via db:plan → db:apply (descent composes). Teardown: sst remove --stage <target>
@@ -398,6 +415,9 @@ Stage resolution:
398
415
  --stage <name> Discover AWS resources for the given stage by querying AWS APIs.
399
416
  Uses SST naming convention: {app}-{stage}-{Resource}-{hash}.
400
417
  Results are cached in .sst/outputs.{stage}.json for 24 hours.
418
+ --refresh Drop the cached stage config and re-discover live. Use after a
419
+ deploy the CLI didn't run, or if a stage command resolves to the
420
+ wrong function. \`everystack deploy\` refreshes this cache for you.
401
421
 
402
422
  Without --stage, reads .sst/outputs.json (written by the last \`sst deploy\`).
403
423
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Diagnostics for a crashed ops-Lambda invoke.
3
+ *
4
+ * A consumer's ops handler can fail to *boot* — a broken top-level import (a dependency missing
5
+ * from the deployed bundle, a version skew, a bad path). Lambda then returns a FunctionError with
6
+ * the opaque `Runtime exited with error: exit status 1` for EVERY action, giving the operator no
7
+ * signal that it's the handler failing to start (not their command) and no path to the cause. The
8
+ * CLI turns that into a report naming the real cause by reading the handler's CloudWatch logs; the
9
+ * pure detection/extraction/formatting lives here, the AWS fetch in aws.ts.
10
+ */
11
+
12
+ /**
13
+ * Whether a Lambda FunctionError message is the handler failing to boot / crashing the runtime — an
14
+ * uncaught init error worth chasing into CloudWatch — rather than an already-descriptive error. Only
15
+ * these opaque cases trigger the log fetch; a clear message passes through untouched.
16
+ */
17
+ export function isOpsHandlerCrash(message: string): boolean {
18
+ return /Runtime exited|exited with error|Runtime\.(ImportModuleError|UserCodeSyntaxError|HandlerNotFound)|Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b/i.test(
19
+ message,
20
+ );
21
+ }
22
+
23
+ /** Log lines worth surfacing: the import error, a runtime error class, or a thrown Error/stack. */
24
+ const SIGNAL = /Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b|Runtime\.\w+|(?:Reference|Type|Syntax|Range)?Error:/;
25
+
26
+ /**
27
+ * Pull the diagnostic lines from raw CloudWatch log text for a crashed ops invoke — the import
28
+ * error / stack frame that names the missing dependency — so an opaque "exit status 1" becomes the
29
+ * real cause. De-duplicates and caps output. Returns null when nothing diagnostic is present.
30
+ */
31
+ export function extractCrashDiagnosis(logText: string): string | null {
32
+ const seen = new Set<string>();
33
+ const out: string[] = [];
34
+ for (const raw of logText.split('\n')) {
35
+ const line = raw.trim();
36
+ if (!line || !SIGNAL.test(line) || seen.has(line)) continue;
37
+ seen.add(line);
38
+ out.push(line);
39
+ if (out.length >= 6) break;
40
+ }
41
+ return out.length ? out.join('\n') : null;
42
+ }
43
+
44
+ /**
45
+ * Assemble the operator-facing report for an ops-handler crash: the raw message, the "it's the
46
+ * handler, not your command" framing, and either the real cause from CloudWatch or a where-to-look
47
+ * hint when the logs couldn't be read.
48
+ */
49
+ export function formatOpsCrashReport(
50
+ baseMessage: string,
51
+ logGroup?: string,
52
+ diagnosis?: string | null,
53
+ ): string {
54
+ const lines = [
55
+ baseMessage,
56
+ '',
57
+ 'The ops handler crashed at startup — usually a missing dependency in the deployed bundle, not a problem with this command.',
58
+ ];
59
+ if (diagnosis) {
60
+ lines.push(
61
+ '',
62
+ `From CloudWatch${logGroup ? ` (${logGroup})` : ''}:`,
63
+ diagnosis,
64
+ '',
65
+ 'Fix the import/dependency and redeploy.',
66
+ );
67
+ } else {
68
+ lines.push('Check its CloudWatch logs for the failing top-level import, then redeploy.');
69
+ }
70
+ return lines.join('\n');
71
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * pipeline-loader — the CLI's window onto @everystack/server/pipeline.
3
+ *
4
+ * The CLI keeps NO compile-time dependency on @everystack/server (the two are
5
+ * mutual peers; hard-importing would be a project-reference cycle — the same
6
+ * reason plugin.ts inlines its types). So the orchestrator's types are mirrored
7
+ * structurally here and the runtime module is loaded lazily via a variable
8
+ * specifier. Every CLI verb that reaches the pipeline framework (pipeline:run,
9
+ * and the db:sync / db:plan visibility) goes through this one door.
10
+ */
11
+
12
+ export type PipelineLane = 'rebuild' | 'curate' | 'all';
13
+
14
+ export interface PipelineStage {
15
+ id: string;
16
+ lane: 'automated' | 'curated' | 'frozen';
17
+ dependsOn?: string[];
18
+ after?: string[];
19
+ heavy?: boolean;
20
+ }
21
+ export interface Pipeline {
22
+ stages: PipelineStage[];
23
+ }
24
+ export interface RunPipelineOptions {
25
+ runId: string;
26
+ lane?: PipelineLane;
27
+ only?: string;
28
+ from?: string;
29
+ to?: string;
30
+ resume?: boolean;
31
+ dryRun?: boolean;
32
+ continueOnError?: boolean;
33
+ actor?: string | null;
34
+ gitRef?: string | null;
35
+ }
36
+ export interface StageRunResult {
37
+ stageId: string;
38
+ outcome: 'applied' | 'skipped' | 'failed' | 'dry-run';
39
+ rows?: number;
40
+ error?: string;
41
+ }
42
+ export interface PipelineRunOutcome {
43
+ runId: string;
44
+ plan: string[];
45
+ results: StageRunResult[];
46
+ failed: boolean;
47
+ }
48
+ export interface PipelineRunSummary {
49
+ runId: string;
50
+ at: string | null;
51
+ applied: number;
52
+ failed: number;
53
+ failedStages: string[];
54
+ }
55
+
56
+ export interface ServerPipeline {
57
+ runPipeline(runner: unknown, pipeline: Pipeline, options: RunPipelineOptions): Promise<PipelineRunOutcome>;
58
+ latestRunId(query: (sql: string) => Promise<any[]>): Promise<string | null>;
59
+ readLastRun(query: (sql: string) => Promise<any[]>): Promise<PipelineRunSummary | null>;
60
+ topoSortStages(stages: PipelineStage[]): PipelineStage[];
61
+ selectStages(ordered: PipelineStage[], opts: Record<string, unknown>): PipelineStage[];
62
+ }
63
+
64
+ /** Lazily load the orchestrator from the @everystack/server peer (runtime only). */
65
+ export async function loadServerPipeline(): Promise<ServerPipeline> {
66
+ const spec = '@everystack/server/pipeline';
67
+ return (await import(spec)) as ServerPipeline;
68
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * pipeline-path — where the consumer's pipeline definition lives.
3
+ *
4
+ * Beside the schema homes (`db/models/`, `db/sql/`, `db/backfills/`), the
5
+ * pipeline is authored at `db/pipeline.ts` (or `db/pipeline/index.ts`),
6
+ * exporting a `pipeline` built with definePipeline. An explicit `--pipeline`
7
+ * always wins; when neither home exists the primary is returned so error
8
+ * messages point at the current convention.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+
13
+ /** Candidate pipeline entrypoints, in preference order. */
14
+ export const PIPELINE_HOMES = ['db/pipeline.ts', 'db/pipeline/index.ts'] as const;
15
+
16
+ export function resolvePipelinePath(
17
+ flag?: string,
18
+ exists: (p: string) => boolean = existsSync,
19
+ ): string {
20
+ if (flag) return flag;
21
+ for (const home of PIPELINE_HOMES) {
22
+ if (exists(home)) return home;
23
+ }
24
+ return PIPELINE_HOMES[0];
25
+ }