-
Notifications
You must be signed in to change notification settings - Fork 526
feat(cli): add supabase notebooks pull #6598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| /** | ||
| * Canonical-path containment shared by every consumer that resolves a user- or | ||
| * checkout-supplied path and must keep it inside the project root: email template | ||
| * `content_path` values and the `supabase/notebooks/` directory. | ||
| */ | ||
|
|
||
| import { lstatSync, readlinkSync, realpathSync, type Stats } from "node:fs"; | ||
| import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; | ||
|
|
||
| // `readlinkSync` bypasses the OS's own `ELOOP` symlink-cycle detection when | ||
| // manually following a dangling/unsearchable/looping symlink one hop at a | ||
| // time (see `canonicalizeExistingPath` below), so that manual follow needs | ||
| // its own explicit bound. | ||
| const MAX_SYMLINK_FOLLOW_DEPTH = 40; | ||
|
|
||
| /** | ||
| * Whether `candidatePath` resolves inside (or exactly to) `root`. Both | ||
| * arguments must already be canonicalized (see {@link canonicalPathForContainment}). | ||
| * Only rejects a genuine `..` traversal — a same-level sibling whose name | ||
| * happens to start with two dots (e.g. `..templates`) is a distinct, | ||
| * in-root path and must not be rejected. | ||
| */ | ||
| export function isPathContainedInRoot(root: string, candidatePath: string): boolean { | ||
| const rel = relative(root, candidatePath); | ||
| return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)); | ||
| } | ||
|
|
||
| /** | ||
| * Canonicalizes `path` when it exists (per `lstatSync`), or returns `undefined` so | ||
| * {@link canonicalPathForContainment} keeps walking up to an existing ancestor. | ||
| * | ||
| * `realpathSync` can throw for a path that exists (dangling symlink, `EACCES` target, `ELOOP`), | ||
| * so such a symlink is followed one hop by hand, bounded by {@link MAX_SYMLINK_FOLLOW_DEPTH}, | ||
| * and its target canonicalized in turn; a chain still unresolved at the bound returns the lexical | ||
| * path so a loop is rejected rather than accepted. An `lstatSync` failure unrelated to the path | ||
| * itself (unreadable ancestor, over-long name) counts as "doesn't exist yet", so an honest in-root | ||
| * path behind a restricted ancestor isn't falsely rejected. | ||
| */ | ||
| function canonicalizeExistingPath(path: string, depth: number): string | undefined { | ||
| try { | ||
| return realpathSync(path); | ||
| } catch { | ||
| // Wraps only the `lstatSync` call, not the recursive canonicalization below it: including | ||
| // that would let a deep throw there return the outer symlink's own lexically-in-root path, | ||
| // turning a rejection into an accept. | ||
| let entry: Stats | undefined; | ||
| try { | ||
| entry = lstatSync(path, { throwIfNoEntry: false }); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| if (entry === undefined) return undefined; | ||
| if (entry.isSymbolicLink()) { | ||
| if (depth < MAX_SYMLINK_FOLLOW_DEPTH) { | ||
| const target = readlinkSync(path); | ||
| return canonicalPathForContainment( | ||
| isAbsolute(target) ? target : join(dirname(path), target), | ||
| depth + 1, | ||
| ); | ||
| } | ||
| // Must not be treated as "doesn't exist": returning `undefined` here would let the | ||
| // ancestor walk-up canonicalize past the whole unresolvable loop and silently accept it | ||
| // instead of failing closed. | ||
| return path; | ||
| } | ||
| // A non-symlink entry `lstat` can see but `realpath` can't resolve — e.g. a chmod-000 | ||
| // directory on Darwin, whose realpath(3) needs search permission on itself, not just its | ||
| // parent. Deferred to the same "doesn't exist yet" ancestor walk-up as a genuinely missing | ||
| // path, since a plain entry can't recurse into a loop. | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Canonicalizes `path` for the containment check, tolerating a path (or an ancestor of it) | ||
| * that genuinely doesn't exist yet — the normal case for a missing template file or a | ||
| * notebooks directory a pull is about to create, which should surface as a missing-file error, | ||
| * not a containment error. Walks up to the deepest existing ancestor, resolves it with | ||
| * `realpathSync` (dereferencing any symlinks, including a symlinked project root itself), then | ||
| * re-appends the missing tail lexically. The walk-up is iterative, not recursive, so it stays | ||
| * correct against a pathologically long chain of missing ancestors; each ancestor still goes | ||
| * through {@link canonicalizeExistingPath}, so an intermediate dangling/unsearchable/looping | ||
| * symlink is followed rather than lexically skipped. | ||
| */ | ||
| export function canonicalPathForContainment(path: string, depth = 0): string { | ||
| const canonical = canonicalizeExistingPath(path, depth); | ||
| if (canonical !== undefined) return canonical; | ||
|
|
||
| const tail: string[] = [basename(path)]; | ||
| let current = dirname(path); | ||
| for (;;) { | ||
| const ancestorCanonical = canonicalizeExistingPath(current, depth); | ||
| if (ancestorCanonical !== undefined) { | ||
| return tail.reduceRight((acc, name) => join(acc, name), ancestorCanonical); | ||
| } | ||
| const parent = dirname(current); | ||
| if (parent === current) { | ||
| return resolve(tail.reduceRight((acc, name) => join(acc, name), current)); | ||
| } | ||
| tail.push(basename(current)); | ||
| current = parent; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { Command } from "effect/unstable/cli"; | ||
| import { notebooksPullCommand } from "./pull/pull.command.ts"; | ||
|
|
||
| export const notebooksCommand = Command.make("notebooks").pipe( | ||
| Command.withDescription( | ||
| "Manage Supabase notebooks: SQL and markdown cells stored with your project, kept in supabase/notebooks/<name>.json.", | ||
| ), | ||
| Command.withShortDescription("Manage Supabase notebooks"), | ||
| Command.withSubcommands([notebooksPullCommand]), | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { Data } from "effect"; | ||
| import { | ||
| actionability, | ||
| type CliErrorActionabilityDeclaration, | ||
| ErrorActionabilityId, | ||
| statusCodeActionability, | ||
| } from "../../shared/telemetry/error-actionability.ts"; | ||
|
|
||
| /** | ||
| * One network / status pair covers every notebook route rather than one pair | ||
| * per call: the notebook commands all walk the same routes, and the failing one | ||
| * is already named by the message the caller templates in ("failed to list | ||
| * notebooks", "failed to update notebook <name>", …). | ||
| */ | ||
| export class NotebooksNetworkError extends Data.TaggedError("NotebooksNetworkError")<{ | ||
| readonly message: string; | ||
| readonly decode?: boolean; | ||
| }> { | ||
| get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { | ||
| return this.decode === true | ||
| ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } | ||
| : actionability.externalNetwork; | ||
| } | ||
| } | ||
|
|
||
| export class NotebooksUnexpectedStatusError extends Data.TaggedError( | ||
| "NotebooksUnexpectedStatusError", | ||
| )<{ | ||
| readonly status: number; | ||
| readonly body: string; | ||
| readonly message: string; | ||
| }> { | ||
| get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { | ||
| return statusCodeActionability(this.status); | ||
| } | ||
| } | ||
|
|
||
| /** A file under `supabase/notebooks/` is not readable, not JSON, or not a notebook. */ | ||
| export class NotebookFileError extends Data.TaggedError("NotebookFileError")<{ | ||
| readonly detail: string; | ||
| readonly suggestion: string; | ||
| }> { | ||
| get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { | ||
| return actionability.invalidConfig; | ||
| } | ||
| } | ||
|
|
||
| /** The single-notebook pull argument is not a Management API notebook UUID. */ | ||
| export class NotebookIdError extends Data.TaggedError("NotebookIdError")<{ | ||
| readonly detail: string; | ||
| readonly suggestion: string; | ||
| }> { | ||
| get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { | ||
| return actionability.invalidInput; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Two project notebooks share one name. The API does not require notebook names | ||
| * to be unique, but a directory of files does — so there is no way to say which | ||
| * of them a local file corresponds to, and guessing would write one user's | ||
| * notebook over another's. | ||
| */ | ||
| export class NotebookNameConflictError extends Data.TaggedError("NotebookNameConflictError")<{ | ||
| readonly detail: string; | ||
| readonly suggestion: string; | ||
| }> { | ||
| get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { | ||
| return actionability.invalidConfig; | ||
| } | ||
| } | ||
|
|
||
| export class NotebooksEnvNotSupportedError extends Data.TaggedError( | ||
| "NotebooksEnvNotSupportedError", | ||
| )<{ | ||
| readonly message: string; | ||
| }> { | ||
| get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { | ||
| return actionability.invalidInput; | ||
| } | ||
| } | ||
|
|
||
| export class NotebooksPaginationError extends Data.TaggedError("NotebooksPaginationError")<{ | ||
| readonly message: string; | ||
| }> { | ||
| get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { | ||
| return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.