From 1d965deba08d76ddd2c29379671712f3c31b704e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:07:18 +0200 Subject: [PATCH 1/8] feat(cli): add experimental stack logs --- .../legacy-db-target-flags.ts | 1 + .../experimental/stack/logs/SIDE_EFFECTS.md | 24 ++ .../experimental/stack/logs/logs.command.ts | 62 ++++ .../experimental/stack/logs/logs.errors.ts | 28 ++ .../experimental/stack/logs/logs.handler.ts | 160 ++++++++++ .../stack/logs/logs.integration.test.ts | 273 ++++++++++++++++++ .../experimental/stack/stack.command.ts | 2 + 7 files changed, 550 insertions(+) create mode 100644 apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experimental/stack/logs/logs.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/logs/logs.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/logs/logs.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts diff --git a/apps/cli/src/command-internal/legacy-db-target-flags.ts b/apps/cli/src/command-internal/legacy-db-target-flags.ts index 24609a253f..d60094a42e 100644 --- a/apps/cli/src/command-internal/legacy-db-target-flags.ts +++ b/apps/cli/src/command-internal/legacy-db-target-flags.ts @@ -77,6 +77,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "password", // db push/pull/dump/remote (StringVarP, short -p) "sql-paths", "schema", + "service", "level", "fail-on", "type", diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md new file mode 100644 index 0000000000..34da911fa8 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -0,0 +1,24 @@ +# `supabase experimental stack logs` + +This command reads retained logs from the managed stack identified by the current project, +an optional `--stack` name, or `--stack-id`. It calls the public `@supabase/stack` logs +and followLogs APIs without loading `supabase/config.toml`, starting the stack, stopping it, +or changing its owner lifecycle. + +## Files read and written + +The stack package reads its normal durable state under `` and +the selected stack's persisted log state. The CLI reads the current workdir from its normal +settings resolution. This command writes no project files, stack state, credentials, or +runtime resources. + +## Output + +Text mode writes one line per retained or followed entry. JSON mode writes one bounded result; +`--follow` requires `--output-format stream-json`, which emits one `log-entry` event per line. +Interrupting follow cancels the log reader and leaves the managed stack owner untouched. + +## Telemetry + +The command uses the standard command instrumentation wrapper. Stack log contents and messages +are not sent as custom telemetry properties. diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts new file mode 100644 index 0000000000..56925d207d --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -0,0 +1,62 @@ +import { Command, Flag } from "effect/unstable/cli"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyExperimentalStackLogs } from "./logs.handler.ts"; + +const config = { + stack: Flag.string("stack").pipe( + Flag.withDescription( + "Select an existing stack by name (defaults to the current project stack).", + ), + Flag.optional, + ), + stackId: Flag.string("stack-id").pipe( + Flag.withDescription("Read logs from an existing stack by id."), + Flag.optional, + ), + service: Flag.choice("service", [ + "database", + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", + ] as const).pipe(Flag.withDescription("Limit logs to one stack service."), Flag.optional), + tail: Flag.integer("tail").pipe( + Flag.filter( + (value) => value >= 0 && value <= 10_000, + (value) => `Expected --tail between 0 and 10000, got ${value}`, + ), + Flag.withDescription("Number of retained log entries to print."), + Flag.withDefault(100), + ), + follow: Flag.boolean("follow").pipe( + Flag.withDescription("Continue printing new log entries until interrupted."), + Flag.withDefault(false), + ), +} as const; + +export const legacyExperimentalStackLogsCommand = Command.make("logs", config).pipe( + Command.withDescription("Read logs from a managed local Supabase stack."), + Command.withShortDescription("Read managed local stack logs"), + Command.withExamples([ + { + command: "supabase experimental stack logs --service database --tail 50", + description: "Print the latest database logs", + }, + { + command: "supabase experimental stack logs --follow --output-format stream-json", + description: "Stream new stack logs as structured events", + }, + ]), + Command.withHandler((flags) => + legacyExperimentalStackLogs(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts b/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts new file mode 100644 index 0000000000..cfe33352b3 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts @@ -0,0 +1,28 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class LegacyExperimentalStackLogsError extends Data.TaggedError( + "LegacyExperimentalStackLogsError", +)<{ + readonly reason: "flags" | "invalid-config" | "lifecycle" | "unknown"; + readonly message: string; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "flags": + return actionability.provideFlags; + case "invalid-config": + return actionability.invalidConfig; + case "lifecycle": + return actionability.invalidConfig; + case "unknown": + return actionability.unknown; + } + } +} diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts new file mode 100644 index 0000000000..4695eafd6b --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -0,0 +1,160 @@ +import { Effect, Match, Option, Stream } from "effect"; +import { + isStackError, + isStackId, + StackIdSchema, + type CapabilityName, + type StackLogEntry, +} from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { LegacyExperimentalStackLogsError } from "./logs.errors.ts"; + +type LegacyExperimentalStackLogsFlags = { + readonly stack: Option.Option; + readonly stackId: Option.Option; + readonly service: Option.Option; + readonly tail: number; + readonly follow: boolean; +}; + +const logsError = (error: unknown): LegacyExperimentalStackLogsError => { + const stackError = isStackError(error) ? error : undefined; + const classification = + stackError === undefined + ? ("unknown" as const) + : Match.value(stackError).pipe( + Match.tag( + "StackNotFoundError", + "InvalidLogCursorError", + "InvalidStackIdentityError", + () => ({ + reason: "flags" as const, + }), + ), + Match.tag( + "StackNotRunningError", + "StackOwnershipConflictError", + "StackLifecycleConflictError", + "StackUpgradeRequiredError", + () => ({ reason: "lifecycle" as const }), + ), + Match.tag("StackStateInvalidError", "StackStateFormatUnsupportedError", () => ({ + reason: "invalid-config" as const, + })), + Match.orElse(() => ({ reason: "unknown" as const })), + ); + return new LegacyExperimentalStackLogsError({ + reason: typeof classification === "string" ? classification : classification.reason, + message: stackError?.message ?? String(error), + cause: error, + }); +}; + +const renderEntry = (entry: StackLogEntry) => + `${entry.timestamp} ${entry.source}/${entry.stream}: ${entry.message}\n`; + +const eventForEntry = (entry: StackLogEntry, source: "history" | "live") => ({ + type: "log-entry" as const, + timestamp: entry.timestamp, + service: entry.source, + stream: entry.stream === "internal" ? ("stderr" as const) : entry.stream, + line: entry.message, + source, +}); + +export const legacyExperimentalStackLogs = Effect.fn("legacy.experimental.stack.logs")(function* ( + flags: LegacyExperimentalStackLogsFlags, +) { + const output = yield* Output; + const settings = yield* LegacyCliSettings; + const stackApi = yield* LegacyExperimentalStackApi; + const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag); + if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value)) + return yield* new LegacyExperimentalStackLogsError({ + reason: "flags", + message: "The legacy -o/--output flag is not supported here; use --output-format json.", + suggestion: "Use --output-format json, --output-format text, or --output-format stream-json.", + }); + if (Option.isSome(flags.stack) && Option.isSome(flags.stackId)) + return yield* new LegacyExperimentalStackLogsError({ + reason: "flags", + message: "--stack and --stack-id cannot be used together", + }); + if (flags.follow && output.format === "json") + return yield* new LegacyExperimentalStackLogsError({ + reason: "flags", + message: "--follow cannot be combined with --output-format json.", + suggestion: "Use --output-format stream-json for follow mode, or omit --follow.", + }); + + const id = Option.isSome(flags.stackId) ? flags.stackId.value : undefined; + const targetOption = + id === undefined + ? yield* stackApi + .findStack({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + }) + .pipe(Effect.mapError(logsError)) + : yield* isStackId(id) + ? Effect.succeed( + Option.some({ id: StackIdSchema.make(id), projectRoot: settings.workdir }), + ) + : Effect.fail( + new LegacyExperimentalStackLogsError({ + reason: "flags", + message: "--stack-id must be a lowercase SHA-256 stack id", + }), + ); + if (Option.isNone(targetOption)) { + if (Option.isSome(flags.stack)) + return yield* new LegacyExperimentalStackLogsError({ + reason: "flags", + message: `No managed stack named "${flags.stack.value}" was found for this project.`, + }); + yield* output.success("No managed stack found for this context.", { + found: false, + entries: [], + }); + return; + } + const stack = yield* stackApi.openStack(targetOption.value.id).pipe(Effect.mapError(logsError)); + const query = { + ...(Option.isSome(flags.service) ? { capabilities: [flags.service.value] } : {}), + tail: flags.tail, + }; + const batch = yield* stack.logs(query).pipe(Effect.mapError(logsError)); + const emitEntries = (source: "history" | "live", entries: ReadonlyArray) => + output.format === "stream-json" + ? Effect.forEach(entries, (entry) => output.event(eventForEntry(entry, source)), { + discard: true, + }) + : Effect.forEach(entries, (entry) => output.raw(renderEntry(entry)), { discard: true }); + if (!flags.follow) { + if (output.format === "text") { + yield* output.raw(batch.entries.map(renderEntry).join("")); + } else if (output.format === "stream-json") { + for (const entry of batch.entries) yield* output.event(eventForEntry(entry, "history")); + } else { + yield* output.success("", batch); + } + return; + } + yield* emitEntries("history", batch.entries); + if (!batch.running) return; + const followQuery = { + ...(Option.isSome(flags.service) ? { capabilities: [flags.service.value] } : {}), + cursor: batch.cursor, + }; + yield* stack.followLogs(followQuery).pipe( + Stream.mapError(logsError), + Stream.runForEach((entry) => + output.format === "stream-json" + ? output.event(eventForEntry(entry, "live")) + : output.raw(renderEntry(entry)), + ), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts new file mode 100644 index 0000000000..f61c32e3c0 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -0,0 +1,273 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Fiber, Layer, Option, Stream } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { StackIdSchema } from "@supabase/stack/effect"; +import type { + EffectStack, + StackLogBatch, + StackLogEntry, + StackStatus, +} from "@supabase/stack/effect"; +import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + ErrorActionabilityId, + actionability, +} from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { legacyExperimentalStackLogs } from "./logs.handler.ts"; +import { LegacyExperimentalStackLogsError } from "./logs.errors.ts"; +import { legacyExperimentalStackLogsCommand } from "./logs.command.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; + +const id = StackIdSchema.make("a".repeat(64)); +const entries: ReadonlyArray = [ + { + cursor: { opaque: "1" }, + timestamp: "2026-09-08T00:00:00.000Z", + source: "database", + stream: "stdout", + message: "database ready", + }, + { + cursor: { opaque: "2" }, + timestamp: "2026-09-08T00:00:01.000Z", + source: "functions", + stream: "stderr", + message: "function failed", + }, +]; + +const status: StackStatus = { + id, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: [], + artifacts: [], +}; + +const flags = (overrides: Partial[0]> = {}) => ({ + stack: Option.none(), + stackId: Option.none(), + service: Option.none<"database" | "functions">(), + tail: 100, + follow: false, + ...overrides, +}); + +function setup(opts: { + root: string; + logs?: (query: unknown) => Effect.Effect; + followLogs?: (query: unknown) => Stream.Stream; +}) { + const out = mockOutput(); + const calls: { readonly queries: unknown[]; opened: string[] } = { queries: [], opened: [] }; + const stack = { + id, + status: () => Effect.succeed(status), + credentials: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + start: () => Effect.die("must not start"), + stop: () => Effect.die("must not stop"), + destroy: () => Effect.die("must not destroy"), + logs: (query?: unknown) => { + calls.queries.push(query); + return ( + opts.logs?.(query) ?? Effect.succeed({ entries, cursor: { opaque: "2" }, running: false }) + ); + }, + followLogs: (query?: unknown) => { + calls.queries.push(query); + return opts.followLogs?.(query) ?? Stream.fromIterable(entries); + }, + } satisfies EffectStack; + const descriptor = { + id, + projectRoot: opts.root, + name: "feature-a", + branchContext: "ordinary-workspace", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const layer = Layer.mergeAll( + out.layer, + mockLegacyCliSettings({ workdir: opts.root }), + Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("must not create"), + findStack: (query) => + Effect.succeed(query.name === "missing" ? Option.none() : Option.some(descriptor)), + openStack: (stackId) => + Effect.sync(() => { + calls.opened.push(stackId); + return stack; + }), + inspectStack: () => Effect.die("must not inspect"), + }), + BunServices.layer, + ); + return { layer, out, calls }; +} + +describe("experimental stack logs", () => { + it.live("parses --service as a value-consuming flag", () => { + let parsed: { service: Option.Option; tail: number } | undefined; + const command = legacyExperimentalStackLogsCommand.pipe( + Command.withHandler((flags) => + Effect.sync(() => { + parsed = { service: flags.service, tail: flags.tail }; + }), + ), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--service", + "database", + "--tail", + "4", + ]); + expect(parsed?.service).toEqual(Option.some("database")); + expect(parsed?.tail).toBe(4); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + + it.effect( + "reads a finite tail and passes the service filter without starting or stopping", + () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ service: Option.some("database"), tail: 2 })); + expect(setupResult.calls.queries).toEqual([{ capabilities: ["database"], tail: 2 }]); + expect(setupResult.calls.opened).toEqual([id]); + expect(setupResult.out.stdoutText).toContain("database ready"); + expect(setupResult.out.stdoutText).toContain("function failed"); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }, + ); + + it.effect( + "streams finite follow output and supports interruption without owner lifecycle calls", + () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-follow-")); + const setupResult = setup({ + root, + logs: () => + Effect.succeed({ entries: [entries[0]!], cursor: { opaque: "1" }, running: true }), + followLogs: (query) => { + expect(query).toEqual({ cursor: { opaque: "1" } }); + return Stream.fromIterable([entries[1]!]); + }, + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ follow: true })); + expect(setupResult.out.stdoutText).toContain("function failed"); + const interrupted = setup({ root, followLogs: () => Stream.never }); + const fiber = yield* Effect.forkChild( + legacyExperimentalStackLogs(flags({ follow: true })).pipe( + Effect.provide(interrupted.layer), + ), + ); + yield* Fiber.interrupt(fiber); + expect(interrupted.calls.opened).toEqual([]); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }, + ); + + it.effect("emits a bounded JSON result", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-json-result-")); + const setupResult = setup({ root }); + const output = mockOutput({ format: "json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ tail: 2 })); + expect(output.messages.find((message) => message.type === "success")?.data).toEqual({ + entries, + cursor: { opaque: "2" }, + running: false, + }); + }).pipe( + Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("emits bounded stream-json log-entry events", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-stream-")); + const setupResult = setup({ root }); + const output = mockOutput({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ tail: 2 })); + expect(output.events).toEqual( + entries.map((entry) => + expect.objectContaining({ + type: "log-entry", + line: entry.message, + source: "history", + }), + ), + ); + }).pipe( + Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects legacy output before selecting a stack", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-output-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackLogs(flags()).pipe(Effect.flip); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.calls.opened).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll(setupResult.layer, Layer.succeed(LegacyOutputFlag, Option.some("json"))), + ), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects JSON follow mode before selecting a stack", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-json-")); + const setupResult = setup({ root }); + const out = mockOutput({ format: "json" }); + const layer = Layer.mergeAll(setupResult.layer, out.layer); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackLogs(flags({ follow: true })).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackLogsError); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(failure.suggestion).toContain("stream-json"); + expect(setupResult.calls.opened).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects invalid targets without package calls", () => + Effect.gen(function* () { + const result = setup({ root: "/tmp/unused" }); + const failure = yield* legacyExperimentalStackLogs( + flags({ stackId: Option.some("invalid") }), + ).pipe(Effect.flip, Effect.provide(result.layer)); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(result.calls.queries).toEqual([]); + expect(result.calls.opened).toEqual([]); + }), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 9a4a4183e6..e57be40dcd 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -6,6 +6,7 @@ import { legacyExperimentalStackStartCommand } from "./start/start.command.ts"; import { legacyExperimentalStackStopCommand } from "./stop/stop.command.ts"; import { legacyExperimentalStackStatusCommand } from "./status/status.command.ts"; import { legacyExperimentalStackListCommand } from "./list/list.command.ts"; +import { legacyExperimentalStackLogsCommand } from "./logs/logs.command.ts"; import { legacyExperimentalStackApiLayer, legacyExperimentalStackTargetResolverLayer, @@ -19,6 +20,7 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( legacyExperimentalStackStopCommand, legacyExperimentalStackStatusCommand, legacyExperimentalStackListCommand, + legacyExperimentalStackLogsCommand, ]), Command.provide(legacyExperimentalStackTargetResolverLayer), Command.provide(legacyExperimentalStackApiLayer), From c02bc5b03d2027f75e115c0ad07e54ce7f46ede0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:19:24 +0200 Subject: [PATCH 2/8] fix(cli): refine experimental stack logs --- .../experimental/stack/logs/SIDE_EFFECTS.md | 4 +- .../experimental/stack/logs/logs.command.ts | 17 ++-- .../experimental/stack/logs/logs.errors.ts | 4 +- .../experimental/stack/logs/logs.handler.ts | 22 ++--- .../stack/logs/logs.integration.test.ts | 87 +++++++++++++++---- apps/cli/src/shared/output/types.ts | 2 +- 6 files changed, 95 insertions(+), 41 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md index 34da911fa8..ef2f197553 100644 --- a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -16,7 +16,9 @@ runtime resources. Text mode writes one line per retained or followed entry. JSON mode writes one bounded result; `--follow` requires `--output-format stream-json`, which emits one `log-entry` event per line. -Interrupting follow cancels the log reader and leaves the managed stack owner untouched. +Follow prints the retained history first and then resumes from its returned cursor. If the stack +is already stopped, it prints the retained history and exits successfully. Interrupting follow +cancels the log reader and leaves the managed stack owner untouched. ## Telemetry diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index 56925d207d..dc4eee9641 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -1,4 +1,5 @@ import { Command, Flag } from "effect/unstable/cli"; +import { CAPABILITY_NAMES } from "@supabase/stack/effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; import { legacyExperimentalStackLogs } from "./logs.handler.ts"; @@ -14,18 +15,10 @@ const config = { Flag.withDescription("Read logs from an existing stack by id."), Flag.optional, ), - service: Flag.choice("service", [ - "database", - "rest", - "auth", - "realtime", - "storage", - "functions", - "studio", - "mail", - "analytics", - "pooler", - ] as const).pipe(Flag.withDescription("Limit logs to one stack service."), Flag.optional), + service: Flag.choice("service", CAPABILITY_NAMES).pipe( + Flag.withDescription("Limit logs to one stack service."), + Flag.optional, + ), tail: Flag.integer("tail").pipe( Flag.filter( (value) => value >= 0 && value <= 10_000, diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts b/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts index cfe33352b3..28f191550b 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts @@ -8,7 +8,7 @@ import { export class LegacyExperimentalStackLogsError extends Data.TaggedError( "LegacyExperimentalStackLogsError", )<{ - readonly reason: "flags" | "invalid-config" | "lifecycle" | "unknown"; + readonly reason: "flags" | "invalid-config" | "lifecycle" | "impossible-state" | "unknown"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -21,6 +21,8 @@ export class LegacyExperimentalStackLogsError extends Data.TaggedError( return actionability.invalidConfig; case "lifecycle": return actionability.invalidConfig; + case "impossible-state": + return actionability.impossibleState; case "unknown": return actionability.unknown; } diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index 4695eafd6b..4f014fd2d3 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -26,20 +26,19 @@ const logsError = (error: unknown): LegacyExperimentalStackLogsError => { stackError === undefined ? ("unknown" as const) : Match.value(stackError).pipe( - Match.tag( - "StackNotFoundError", - "InvalidLogCursorError", - "InvalidStackIdentityError", - () => ({ - reason: "flags" as const, - }), - ), + Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => ({ + reason: "flags" as const, + })), + Match.tag("InvalidLogCursorError", () => ({ reason: "impossible-state" as const })), Match.tag( "StackNotRunningError", "StackOwnershipConflictError", "StackLifecycleConflictError", "StackUpgradeRequiredError", - () => ({ reason: "lifecycle" as const }), + () => ({ + reason: "lifecycle" as const, + suggestion: "Run supabase experimental stack start before reading logs.", + }), ), Match.tag("StackStateInvalidError", "StackStateFormatUnsupportedError", () => ({ reason: "invalid-config" as const, @@ -49,6 +48,9 @@ const logsError = (error: unknown): LegacyExperimentalStackLogsError => { return new LegacyExperimentalStackLogsError({ reason: typeof classification === "string" ? classification : classification.reason, message: stackError?.message ?? String(error), + ...(typeof classification !== "string" && "suggestion" in classification + ? { suggestion: classification.suggestion } + : {}), cause: error, }); }; @@ -60,7 +62,7 @@ const eventForEntry = (entry: StackLogEntry, source: "history" | "live") => ({ type: "log-entry" as const, timestamp: entry.timestamp, service: entry.source, - stream: entry.stream === "internal" ? ("stderr" as const) : entry.stream, + stream: entry.stream, line: entry.message, source, }); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index f61c32e3c0..62797c5983 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Fiber, Layer, Option, Stream } from "effect"; +import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { StackIdSchema } from "@supabase/stack/effect"; import type { @@ -42,6 +42,13 @@ const entries: ReadonlyArray = [ message: "function failed", }, ]; +const internalEntry: StackLogEntry = { + cursor: { opaque: "internal-1" }, + timestamp: "2026-09-08T00:00:02.000Z", + source: "supervisor", + stream: "internal", + message: "stack supervisor ready", +}; const status: StackStatus = { id, @@ -69,15 +76,20 @@ function setup(opts: { followLogs?: (query: unknown) => Stream.Stream; }) { const out = mockOutput(); - const calls: { readonly queries: unknown[]; opened: string[] } = { queries: [], opened: [] }; + const calls: { + readonly queries: unknown[]; + opened: string[]; + stopCalls: number; + destroyCalls: number; + } = { queries: [], opened: [], stopCalls: 0, destroyCalls: 0 }; const stack = { id, status: () => Effect.succeed(status), credentials: () => Effect.die("unused"), prepare: () => Effect.die("unused"), start: () => Effect.die("must not start"), - stop: () => Effect.die("must not stop"), - destroy: () => Effect.die("must not destroy"), + stop: () => Effect.sync(() => void calls.stopCalls++), + destroy: () => Effect.sync(() => void calls.destroyCalls++), logs: (query?: unknown) => { calls.queries.push(query); return ( @@ -174,14 +186,32 @@ describe("experimental stack logs", () => { return Effect.gen(function* () { yield* legacyExperimentalStackLogs(flags({ follow: true })); expect(setupResult.out.stdoutText).toContain("function failed"); - const interrupted = setup({ root, followLogs: () => Stream.never }); + const followStarted = yield* Deferred.make(); + let finalized = false; + const interrupted = setup({ + root, + logs: () => + Effect.succeed({ entries: [entries[0]!], cursor: { opaque: "1" }, running: true }), + followLogs: (): Stream.Stream => + Stream.fromEffect( + Effect.as(Deferred.succeed(followStarted, undefined), undefined), + ).pipe( + Stream.flatMap(() => Stream.empty), + Stream.concat(Stream.never), + Stream.ensuring(Effect.sync(() => void (finalized = true))), + ), + }); const fiber = yield* Effect.forkChild( legacyExperimentalStackLogs(flags({ follow: true })).pipe( Effect.provide(interrupted.layer), ), ); + yield* Deferred.await(followStarted); + expect(interrupted.calls.opened).toEqual([id]); yield* Fiber.interrupt(fiber); - expect(interrupted.calls.opened).toEqual([]); + expect(finalized).toBe(true); + expect(interrupted.calls.stopCalls).toBe(0); + expect(interrupted.calls.destroyCalls).toBe(0); }).pipe( Effect.provide(setupResult.layer), Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), @@ -208,25 +238,50 @@ describe("experimental stack logs", () => { it.effect("emits bounded stream-json log-entry events", () => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-stream-")); - const setupResult = setup({ root }); + const setupResult = setup({ + root, + logs: () => + Effect.succeed({ + entries: [internalEntry], + cursor: { opaque: "internal-1" }, + running: false, + }), + }); const output = mockOutput({ format: "stream-json" }); return Effect.gen(function* () { yield* legacyExperimentalStackLogs(flags({ tail: 2 })); - expect(output.events).toEqual( - entries.map((entry) => - expect.objectContaining({ - type: "log-entry", - line: entry.message, - source: "history", - }), - ), - ); + expect(output.events).toEqual([ + expect.objectContaining({ + type: "log-entry", + line: internalEntry.message, + stream: "internal", + source: "history", + }), + ]); }).pipe( Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), ); }); + it.effect("finishes follow after printing retained history when the stack is stopped", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-stopped-")); + const setupResult = setup({ + root, + logs: () => + Effect.succeed({ entries: [entries[0]!], cursor: { opaque: "1" }, running: false }), + followLogs: () => Stream.die("follow must not be opened for a stopped stack"), + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ follow: true })); + expect(setupResult.out.stdoutText).toContain("database ready"); + expect(setupResult.calls.queries).toEqual([{ tail: 100 }]); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + it.effect("rejects legacy output before selecting a stack", () => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-output-")); const setupResult = setup({ root }); diff --git a/apps/cli/src/shared/output/types.ts b/apps/cli/src/shared/output/types.ts index 981bafe186..6c935aa289 100644 --- a/apps/cli/src/shared/output/types.ts +++ b/apps/cli/src/shared/output/types.ts @@ -11,7 +11,7 @@ export type StreamEvent = readonly type: "log-entry"; readonly timestamp: string; readonly service: string; - readonly stream: "stdout" | "stderr"; + readonly stream: "stdout" | "stderr" | "internal"; readonly line: string; readonly source: "history" | "live"; } From 7a86e2055926555dd1b8745fe6ada0869e1a3eb0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:29:37 +0200 Subject: [PATCH 3/8] fix(cli): refine experimental stack logs reporting --- .../experimental/stack/logs/SIDE_EFFECTS.md | 5 +- .../experimental/stack/logs/logs.handler.ts | 25 +++--- .../stack/logs/logs.integration.test.ts | 80 +++++++++++++++++-- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md index ef2f197553..47e2174207 100644 --- a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -15,10 +15,11 @@ runtime resources. ## Output Text mode writes one line per retained or followed entry. JSON mode writes one bounded result; -`--follow` requires `--output-format stream-json`, which emits one `log-entry` event per line. +`--follow` is rejected with `--output-format json`; use the default text mode or +`--output-format stream-json`, which emits one `log-entry` event per line. Follow prints the retained history first and then resumes from its returned cursor. If the stack is already stopped, it prints the retained history and exits successfully. Interrupting follow -cancels the log reader and leaves the managed stack owner untouched. +cancels the log reader, exits with status `130`, and leaves the managed stack owner untouched. ## Telemetry diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index 4f014fd2d3..98f751756e 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -30,19 +30,22 @@ const logsError = (error: unknown): LegacyExperimentalStackLogsError => { reason: "flags" as const, })), Match.tag("InvalidLogCursorError", () => ({ reason: "impossible-state" as const })), - Match.tag( - "StackNotRunningError", - "StackOwnershipConflictError", - "StackLifecycleConflictError", - "StackUpgradeRequiredError", - () => ({ - reason: "lifecycle" as const, - suggestion: "Run supabase experimental stack start before reading logs.", - }), - ), + Match.tag("StackNotRunningError", () => ({ + reason: "lifecycle" as const, + suggestion: "Run supabase experimental stack start before reading logs.", + })), + Match.tag("StackOwnershipConflictError", "StackLifecycleConflictError", () => ({ + reason: "lifecycle" as const, + suggestion: "The stack owner is busy or shutting down; retry shortly.", + })), + Match.tag("StackUpgradeRequiredError", () => ({ + reason: "lifecycle" as const, + suggestion: "Upgrade the CLI to a compatible stack version before reading logs.", + })), Match.tag("StackStateInvalidError", "StackStateFormatUnsupportedError", () => ({ reason: "invalid-config" as const, })), + Match.tag("InvalidProjectRootError", () => ({ reason: "invalid-config" as const })), Match.orElse(() => ({ reason: "unknown" as const })), ); return new LegacyExperimentalStackLogsError({ @@ -141,7 +144,7 @@ export const legacyExperimentalStackLogs = Effect.fn("legacy.experimental.stack. } else if (output.format === "stream-json") { for (const entry of batch.entries) yield* output.event(eventForEntry(entry, "history")); } else { - yield* output.success("", batch); + yield* output.success("", { found: true, id: stack.id, ...batch }); } return; } diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index 62797c5983..6583769783 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -5,11 +5,18 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; -import { StackIdSchema } from "@supabase/stack/effect"; +import { + InvalidProjectRootError, + StackIdSchema, + StackOwnershipConflictError, + StackUpgradeRequiredError, +} from "@supabase/stack/effect"; import type { EffectStack, + OpenStackError, StackLogBatch, StackLogEntry, + StackDiscoveryError, StackStatus, } from "@supabase/stack/effect"; import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; @@ -74,6 +81,8 @@ function setup(opts: { root: string; logs?: (query: unknown) => Effect.Effect; followLogs?: (query: unknown) => Stream.Stream; + openFailure?: OpenStackError; + findFailure?: StackDiscoveryError; }) { const out = mockOutput(); const calls: { @@ -115,12 +124,16 @@ function setup(opts: { Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("must not create"), findStack: (query) => - Effect.succeed(query.name === "missing" ? Option.none() : Option.some(descriptor)), + opts.findFailure === undefined + ? Effect.succeed(query.name === "missing" ? Option.none() : Option.some(descriptor)) + : Effect.fail(opts.findFailure), openStack: (stackId) => - Effect.sync(() => { - calls.opened.push(stackId); - return stack; - }), + opts.openFailure === undefined + ? Effect.sync(() => { + calls.opened.push(stackId); + return stack; + }) + : Effect.fail(opts.openFailure), inspectStack: () => Effect.die("must not inspect"), }), BunServices.layer, @@ -226,6 +239,8 @@ describe("experimental stack logs", () => { return Effect.gen(function* () { yield* legacyExperimentalStackLogs(flags({ tail: 2 })); expect(output.messages.find((message) => message.type === "success")?.data).toEqual({ + found: true, + id, entries, cursor: { opaque: "2" }, running: false, @@ -325,4 +340,57 @@ describe("experimental stack logs", () => { expect(result.calls.opened).toEqual([]); }), ); + + it.effect("classifies an invalid project root as invalid config", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-invalid-root-")); + const setupResult = setup({ + root, + findFailure: new InvalidProjectRootError({ message: "Project root is invalid" }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackLogs(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("invalid-config"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + expect(setupResult.calls.opened).toEqual([]); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("gives retry guidance for busy owners and upgrade guidance for old owners", () => { + const busyRoot = mkdtempSync(join(tmpdir(), "supabase-stack-logs-busy-")); + const upgradeRoot = mkdtempSync(join(tmpdir(), "supabase-stack-logs-upgrade-")); + const busy = setup({ + root: busyRoot, + openFailure: new StackOwnershipConflictError({ message: "Stack owner is busy" }), + }); + const upgrade = setup({ + root: upgradeRoot, + openFailure: new StackUpgradeRequiredError({ + expectedRelease: "next", + actualRelease: "current", + message: "Stack upgrade required", + }), + }); + return Effect.gen(function* () { + const busyFailure = yield* legacyExperimentalStackLogs(flags()).pipe( + Effect.flip, + Effect.provide(busy.layer), + ); + const upgradeFailure = yield* legacyExperimentalStackLogs(flags()).pipe( + Effect.flip, + Effect.provide(upgrade.layer), + ); + expect(busyFailure.suggestion).toContain("retry"); + expect(upgradeFailure.suggestion).toContain("compatible stack version"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(busyRoot, { recursive: true, force: true }); + rmSync(upgradeRoot, { recursive: true, force: true }); + }), + ), + ); + }); }); From 3f6b37b118a5f3036ec4394096ddc14841a582a5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:38:17 +0200 Subject: [PATCH 4/8] fix(cli): clarify experimental stack log ownership guidance --- .../src/commands/experimental/stack/logs/logs.handler.ts | 9 +++++++-- .../experimental/stack/logs/logs.integration.test.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index 98f751756e..68cc54ff1c 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -34,9 +34,14 @@ const logsError = (error: unknown): LegacyExperimentalStackLogsError => { reason: "lifecycle" as const, suggestion: "Run supabase experimental stack start before reading logs.", })), - Match.tag("StackOwnershipConflictError", "StackLifecycleConflictError", () => ({ + Match.tag("StackOwnershipConflictError", () => ({ reason: "lifecycle" as const, - suggestion: "The stack owner is busy or shutting down; retry shortly.", + suggestion: + "Run supabase experimental stack status to inspect ownership; retry if the stack is shutting down.", + })), + Match.tag("StackLifecycleConflictError", () => ({ + reason: "lifecycle" as const, + suggestion: "The stack owner is shutting down; retry shortly.", })), Match.tag("StackUpgradeRequiredError", () => ({ reason: "lifecycle" as const, diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index 6583769783..99d89ce2eb 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -382,7 +382,7 @@ describe("experimental stack logs", () => { Effect.flip, Effect.provide(upgrade.layer), ); - expect(busyFailure.suggestion).toContain("retry"); + expect(busyFailure.suggestion).toContain("status to inspect ownership"); expect(upgradeFailure.suggestion).toContain("compatible stack version"); }).pipe( Effect.ensuring( From c79aed7c559e4244a4a93e53e02a8ae92f6917a7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:46:41 +0200 Subject: [PATCH 5/8] test(cli): complete logs stack api mock --- .../commands/experimental/stack/logs/logs.integration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index 99d89ce2eb..4008014d18 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -123,6 +123,7 @@ function setup(opts: { mockLegacyCliSettings({ workdir: opts.root }), Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("must not create"), + listStacks: () => Effect.succeed([]), findStack: (query) => opts.findFailure === undefined ? Effect.succeed(query.name === "missing" ? Option.none() : Option.some(descriptor)) From 60ea626109f27ab62ae63a491b93c63ee714f0e1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:03:21 +0200 Subject: [PATCH 6/8] fix(cli): polish experimental stack logs --- .../legacy-db-target-flags.ts | 4 +- .../experimental/stack/logs/SIDE_EFFECTS.md | 14 +++- .../experimental/stack/logs/logs.command.ts | 11 ++- .../experimental/stack/logs/logs.handler.ts | 12 +-- .../stack/logs/logs.integration.test.ts | 75 ++++++++++++++++++- docs/output.md | 4 + 6 files changed, 103 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/command-internal/legacy-db-target-flags.ts b/apps/cli/src/command-internal/legacy-db-target-flags.ts index d60094a42e..dbeafdc527 100644 --- a/apps/cli/src/command-internal/legacy-db-target-flags.ts +++ b/apps/cli/src/command-internal/legacy-db-target-flags.ts @@ -77,7 +77,6 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "password", // db push/pull/dump/remote (StringVarP, short -p) "sql-paths", "schema", - "service", "level", "fail-on", "type", @@ -182,7 +181,8 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "link", "issue-type", "improvement", - // experimental stack start flags + // experimental stack flags + "service", "stack", "stack-id", "preparation", diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md index 47e2174207..10d8a3ad51 100644 --- a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -16,10 +16,18 @@ runtime resources. Text mode writes one line per retained or followed entry. JSON mode writes one bounded result; `--follow` is rejected with `--output-format json`; use the default text mode or -`--output-format stream-json`, which emits one `log-entry` event per line. +`--output-format stream-json`, which emits one `log-entry` event per line. Each event has +`type: "log-entry"`, `timestamp`, `service`, `stream`, `line`, and `source`; `stream` is +`stdout`, `stderr`, or `internal`, and `source` is `history` or `live`. Follow prints the retained history first and then resumes from its returned cursor. If the stack -is already stopped, it prints the retained history and exits successfully. Interrupting follow -cancels the log reader, exits with status `130`, and leaves the managed stack owner untouched. +is already stopped, it prints the retained history and exits successfully. With no `--stack` or +`--stack-id`, an absent default stack prints a successful empty result. A missing named stack +fails with status `1`. The legacy `-o`/`--output` flag is rejected; use `--output-format`. + +Successful reads, including an absent default stack and a stopped stack, exit with status `0`. +Invalid flags, missing named stacks, and stack read failures exit with status `1`. Interrupting +follow cancels the log reader, exits with status `130`, and leaves the managed stack owner +untouched. ## Telemetry diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index dc4eee9641..b046690251 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -4,6 +4,8 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; import { legacyExperimentalStackLogs } from "./logs.handler.ts"; +const MAX_TAIL = 10_000; + const config = { stack: Flag.string("stack").pipe( Flag.withDescription( @@ -21,13 +23,16 @@ const config = { ), tail: Flag.integer("tail").pipe( Flag.filter( - (value) => value >= 0 && value <= 10_000, - (value) => `Expected --tail between 0 and 10000, got ${value}`, + (value) => value >= 0 && value <= MAX_TAIL, + (value) => `Expected --tail between 0 and ${MAX_TAIL}, got ${value}`, + ), + Flag.withDescription( + "Number of retained log entries to print. Use 0 with --follow to skip retained history.", ), - Flag.withDescription("Number of retained log entries to print."), Flag.withDefault(100), ), follow: Flag.boolean("follow").pipe( + Flag.withAlias("f"), Flag.withDescription("Continue printing new log entries until interrupted."), Flag.withDefault(false), ), diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index 68cc54ff1c..a8e4036873 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -110,9 +110,7 @@ export const legacyExperimentalStackLogs = Effect.fn("legacy.experimental.stack. }) .pipe(Effect.mapError(logsError)) : yield* isStackId(id) - ? Effect.succeed( - Option.some({ id: StackIdSchema.make(id), projectRoot: settings.workdir }), - ) + ? Effect.succeed(Option.some({ id: StackIdSchema.make(id) })) : Effect.fail( new LegacyExperimentalStackLogsError({ reason: "flags", @@ -144,12 +142,10 @@ export const legacyExperimentalStackLogs = Effect.fn("legacy.experimental.stack. }) : Effect.forEach(entries, (entry) => output.raw(renderEntry(entry)), { discard: true }); if (!flags.follow) { - if (output.format === "text") { - yield* output.raw(batch.entries.map(renderEntry).join("")); - } else if (output.format === "stream-json") { - for (const entry of batch.entries) yield* output.event(eventForEntry(entry, "history")); - } else { + if (output.format === "json") { yield* output.success("", { found: true, id: stack.id, ...batch }); + } else { + yield* emitEntries("history", batch.entries); } return; } diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index 4008014d18..a1da8d6506 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -83,6 +83,7 @@ function setup(opts: { followLogs?: (query: unknown) => Stream.Stream; openFailure?: OpenStackError; findFailure?: StackDiscoveryError; + noDefault?: boolean; }) { const out = mockOutput(); const calls: { @@ -126,7 +127,11 @@ function setup(opts: { listStacks: () => Effect.succeed([]), findStack: (query) => opts.findFailure === undefined - ? Effect.succeed(query.name === "missing" ? Option.none() : Option.some(descriptor)) + ? Effect.succeed( + query.name === "missing" || (query.name === undefined && opts.noDefault) + ? Option.none() + : Option.some(descriptor), + ) : Effect.fail(opts.findFailure), openStack: (stackId) => opts.openFailure === undefined @@ -184,6 +189,50 @@ describe("experimental stack logs", () => { }, ); + it.effect("rejects conflicting targets before resolving a stack", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-conflict-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackLogs( + flags({ stack: Option.some("feature-a"), stackId: Option.some(id) }), + ).pipe(Effect.flip); + expect(failure.reason).toBe("flags"); + expect(setupResult.calls.queries).toEqual([]); + expect(setupResult.calls.opened).toEqual([]); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("distinguishes an absent default stack from a missing named stack", () => { + const defaultRoot = mkdtempSync(join(tmpdir(), "supabase-stack-logs-default-missing-")); + const namedRoot = mkdtempSync(join(tmpdir(), "supabase-stack-logs-named-missing-")); + const absent = setup({ root: defaultRoot, noDefault: true }); + const named = setup({ root: namedRoot }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags()).pipe(Effect.provide(absent.layer)); + expect(absent.out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "No managed stack found for this context.", + }), + ); + const failure = yield* legacyExperimentalStackLogs( + flags({ stack: Option.some("missing") }), + ).pipe(Effect.flip, Effect.provide(named.layer)); + expect(failure.reason).toBe("flags"); + expect(failure.message).toContain("No managed stack named"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(defaultRoot, { recursive: true, force: true }); + rmSync(namedRoot, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect( "streams finite follow output and supports interruption without owner lifecycle calls", () => { @@ -280,6 +329,30 @@ describe("experimental stack logs", () => { ); }); + it.effect("emits history and live events while following stream-json", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-stream-follow-")); + const setupResult = setup({ + root, + logs: () => + Effect.succeed({ entries: [entries[0]!], cursor: { opaque: "1" }, running: true }), + followLogs: () => Stream.fromIterable([entries[1]!]), + }); + const output = mockOutput({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackLogs(flags({ follow: true })).pipe( + Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), + ); + expect(output.events).toEqual([ + expect.objectContaining({ + type: "log-entry", + source: "history", + line: entries[0]!.message, + }), + expect.objectContaining({ type: "log-entry", source: "live", line: entries[1]!.message }), + ]); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + it.effect("finishes follow after printing retained history when the stack is stopped", () => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-logs-stopped-")); const setupResult = setup({ diff --git a/docs/output.md b/docs/output.md index 6329df448b..cd8dacc40c 100644 --- a/docs/output.md +++ b/docs/output.md @@ -44,6 +44,10 @@ yield* output.fail({ code: "InvalidTokenError", message: "Bad token format" }) ## How It Works +`stream-json` commands may emit `log-entry` events for line-oriented output. A log-entry event +has `type: "log-entry"`, `timestamp`, `service`, `stream`, `line`, and `source`; stack logs use +`stream: "stdout" | "stderr" | "internal"` and `source: "history" | "live"`. + Each format has its own layer implementation. The root command provides the appropriate layer based on the `--output-format` flag: ```ts From 22e417923f3d304ba71e441a4a07eb96ebf5c164 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:48:54 +0200 Subject: [PATCH 7/8] chore(cli): annotate stack logs fixtures --- .../commands/experimental/stack/logs/logs.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index a1da8d6506..9d0e5a1045 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -1,5 +1,7 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; From ee53c9df5dd73a429b90f4ca9a11f81757b4d011 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 14:12:05 +0200 Subject: [PATCH 8/8] fix(cli): record shorthand log follow usage --- apps/cli/src/commands/experimental/stack/logs/logs.command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index b046690251..2104b99aad 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -53,7 +53,7 @@ export const legacyExperimentalStackLogsCommand = Command.make("logs", config).p ]), Command.withHandler((flags) => legacyExperimentalStackLogs(flags).pipe( - withLegacyCommandInstrumentation({ flags, config }), + withLegacyCommandInstrumentation({ flags, config, aliases: { f: "follow" } }), withJsonErrorHandling, ), ),