opencode-supabase 0.1.0 → 0.1.2-alpha.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.
- package/README.md +51 -0
- package/package.json +4 -2
- package/skills/.upstream.json +13 -0
- package/skills/supabase/SKILL.md +112 -0
- package/skills/supabase/assets/feedback-issue-template.md +17 -0
- package/skills/supabase/references/skill-feedback.md +17 -0
- package/skills/supabase-postgres-best-practices/SKILL.md +64 -0
- package/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
- package/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
- package/skills/supabase-postgres-best-practices/references/_template.md +34 -0
- package/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
- package/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
- package/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
- package/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
- package/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
- package/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
- package/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
- package/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
- package/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
- package/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
- package/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
- package/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
- package/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
- package/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
- package/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
- package/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
- package/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
- package/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
- package/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
- package/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
- package/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
- package/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
- package/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
- package/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
- package/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
- package/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
- package/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
- package/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
- package/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
- package/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
- package/skills/supabase-postgres-best-practices/references/security-rls-performance.md +57 -0
- package/src/server/index.ts +6 -0
- package/src/server/skills.ts +84 -0
- package/src/tui/commands.ts +1 -1
- package/src/tui/dialog.tsx +249 -46
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Apply Principle of Least Privilege
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Reduced attack surface, better audit trail
|
|
5
|
+
tags: privileges, security, roles, permissions
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Apply Principle of Least Privilege
|
|
9
|
+
|
|
10
|
+
Grant only the minimum permissions required. Never use superuser for application queries.
|
|
11
|
+
|
|
12
|
+
**Incorrect (overly broad permissions):**
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
-- Application uses superuser connection
|
|
16
|
+
-- Or grants ALL to application role
|
|
17
|
+
grant all privileges on all tables in schema public to app_user;
|
|
18
|
+
grant all privileges on all sequences in schema public to app_user;
|
|
19
|
+
|
|
20
|
+
-- Any SQL injection becomes catastrophic
|
|
21
|
+
-- drop table users; cascades to everything
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Correct (minimal, specific grants):**
|
|
25
|
+
|
|
26
|
+
```sql
|
|
27
|
+
-- Create role with no default privileges
|
|
28
|
+
create role app_readonly nologin;
|
|
29
|
+
|
|
30
|
+
-- Grant only SELECT on specific tables
|
|
31
|
+
grant usage on schema public to app_readonly;
|
|
32
|
+
grant select on public.products, public.categories to app_readonly;
|
|
33
|
+
|
|
34
|
+
-- Create role for writes with limited scope
|
|
35
|
+
create role app_writer nologin;
|
|
36
|
+
grant usage on schema public to app_writer;
|
|
37
|
+
grant select, insert, update on public.orders to app_writer;
|
|
38
|
+
grant usage on sequence orders_id_seq to app_writer;
|
|
39
|
+
-- No DELETE permission
|
|
40
|
+
|
|
41
|
+
-- Login role inherits from these
|
|
42
|
+
create role app_user login password 'xxx';
|
|
43
|
+
grant app_writer to app_user;
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Revoke public defaults:
|
|
47
|
+
|
|
48
|
+
```sql
|
|
49
|
+
-- Revoke default public access
|
|
50
|
+
revoke all on schema public from public;
|
|
51
|
+
revoke all on all tables in schema public from public;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Reference: [Roles and Privileges](https://supabase.com/blog/postgres-roles-and-privileges)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Enable Row Level Security for Multi-Tenant Data
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: Database-enforced tenant isolation, prevent data leaks
|
|
5
|
+
tags: rls, row-level-security, multi-tenant, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Enable Row Level Security for Multi-Tenant Data
|
|
9
|
+
|
|
10
|
+
Row Level Security (RLS) enforces data access at the database level, ensuring users only see their own data.
|
|
11
|
+
|
|
12
|
+
**Incorrect (application-level filtering only):**
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
-- Relying only on application to filter
|
|
16
|
+
select * from orders where user_id = $current_user_id;
|
|
17
|
+
|
|
18
|
+
-- Bug or bypass means all data is exposed!
|
|
19
|
+
select * from orders; -- Returns ALL orders
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Correct (database-enforced RLS):**
|
|
23
|
+
|
|
24
|
+
```sql
|
|
25
|
+
-- Enable RLS on the table
|
|
26
|
+
alter table orders enable row level security;
|
|
27
|
+
|
|
28
|
+
-- Create policy for users to see only their orders
|
|
29
|
+
create policy orders_user_policy on orders
|
|
30
|
+
for all
|
|
31
|
+
using (user_id = current_setting('app.current_user_id')::bigint);
|
|
32
|
+
|
|
33
|
+
-- Force RLS even for table owners
|
|
34
|
+
alter table orders force row level security;
|
|
35
|
+
|
|
36
|
+
-- Set user context and query
|
|
37
|
+
set app.current_user_id = '123';
|
|
38
|
+
select * from orders; -- Only returns orders for user 123
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Policy for authenticated role:
|
|
42
|
+
|
|
43
|
+
```sql
|
|
44
|
+
create policy orders_user_policy on orders
|
|
45
|
+
for all
|
|
46
|
+
to authenticated
|
|
47
|
+
using (user_id = auth.uid());
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Reference: [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Optimize RLS Policies for Performance
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: 5-10x faster RLS queries with proper patterns
|
|
5
|
+
tags: rls, performance, security, optimization
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Optimize RLS Policies for Performance
|
|
9
|
+
|
|
10
|
+
Poorly written RLS policies can cause severe performance issues. Use subqueries and indexes strategically.
|
|
11
|
+
|
|
12
|
+
**Incorrect (function called for every row):**
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
create policy orders_policy on orders
|
|
16
|
+
using (auth.uid() = user_id); -- auth.uid() called per row!
|
|
17
|
+
|
|
18
|
+
-- With 1M rows, auth.uid() is called 1M times
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Correct (wrap functions in SELECT):**
|
|
22
|
+
|
|
23
|
+
```sql
|
|
24
|
+
create policy orders_policy on orders
|
|
25
|
+
using ((select auth.uid()) = user_id); -- Called once, cached
|
|
26
|
+
|
|
27
|
+
-- 100x+ faster on large tables
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Use security definer functions for complex checks:
|
|
31
|
+
|
|
32
|
+
```sql
|
|
33
|
+
-- Create helper function (runs as definer, bypasses RLS)
|
|
34
|
+
create or replace function is_team_member(team_id bigint)
|
|
35
|
+
returns boolean
|
|
36
|
+
language sql
|
|
37
|
+
security definer
|
|
38
|
+
set search_path = ''
|
|
39
|
+
as $$
|
|
40
|
+
select exists (
|
|
41
|
+
select 1 from public.team_members
|
|
42
|
+
where team_id = $1 and user_id = (select auth.uid())
|
|
43
|
+
);
|
|
44
|
+
$$;
|
|
45
|
+
|
|
46
|
+
-- Use in policy (indexed lookup, not per-row check)
|
|
47
|
+
create policy team_orders_policy on orders
|
|
48
|
+
using ((select is_team_member(team_id)));
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Always add indexes on columns used in RLS policies:
|
|
52
|
+
|
|
53
|
+
```sql
|
|
54
|
+
create index orders_user_id_idx on orders (user_id);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Reference: [RLS Performance](https://supabase.com/docs/guides/database/postgres/row-level-security#rls-performance-recommendations)
|
package/src/server/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin";
|
|
|
2
2
|
|
|
3
3
|
import { createServerLogWriter, createSupabaseLogger } from "../shared/log.ts";
|
|
4
4
|
import { createSupabaseAuth } from "./auth.ts";
|
|
5
|
+
import { registerSupabaseSkillPaths } from "./skills.ts";
|
|
5
6
|
import { createSupabaseTools } from "./tools.ts";
|
|
6
7
|
|
|
7
8
|
const server: Plugin = async (input, options) => {
|
|
@@ -10,6 +11,11 @@ const server: Plugin = async (input, options) => {
|
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
return {
|
|
14
|
+
config: async (config) => {
|
|
15
|
+
registerSupabaseSkillPaths(config, options, {
|
|
16
|
+
warn: (message, data) => logger.warn(message, data as Record<string, unknown>),
|
|
17
|
+
});
|
|
18
|
+
},
|
|
13
19
|
auth: createSupabaseAuth(input, options, { logger }),
|
|
14
20
|
tool: createSupabaseTools(input, options, { logger }),
|
|
15
21
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const BUNDLED_SUPABASE_SKILLS = [
|
|
5
|
+
"supabase",
|
|
6
|
+
"supabase-postgres-best-practices",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
export type BundledSupabaseSkill = (typeof BUNDLED_SUPABASE_SKILLS)[number];
|
|
10
|
+
|
|
11
|
+
type Warn = (message: string, data?: unknown) => void;
|
|
12
|
+
|
|
13
|
+
type ResolverDeps = {
|
|
14
|
+
warn?: Warn;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type RegisterDeps = ResolverDeps & {
|
|
18
|
+
skillsRoot?: string;
|
|
19
|
+
exists?: (path: string) => boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ConfigWithSkills = object & {
|
|
23
|
+
skills?: {
|
|
24
|
+
paths?: string[];
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pluginSkillsOption(options: unknown) {
|
|
33
|
+
if (!isRecord(options) || !("skills" in options)) return true;
|
|
34
|
+
return options.skills;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveEnabledSupabaseSkills(options: unknown, deps: ResolverDeps = {}) {
|
|
38
|
+
const value = pluginSkillsOption(options);
|
|
39
|
+
if (value === false) return [];
|
|
40
|
+
if (value === true || value === undefined) return [...BUNDLED_SUPABASE_SKILLS];
|
|
41
|
+
|
|
42
|
+
if (!isRecord(value)) {
|
|
43
|
+
deps.warn?.("invalid Supabase skills option; loading bundled skills", { value });
|
|
44
|
+
return [...BUNDLED_SUPABASE_SKILLS];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const known = new Set<string>(BUNDLED_SUPABASE_SKILLS);
|
|
48
|
+
for (const key of Object.keys(value)) {
|
|
49
|
+
if (!known.has(key)) {
|
|
50
|
+
deps.warn?.("unknown Supabase bundled skill option ignored", { skill: key });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return BUNDLED_SUPABASE_SKILLS.filter((skill) => value[skill] !== false);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function defaultSkillsRoot() {
|
|
58
|
+
return path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../skills");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function registerSupabaseSkillPaths(
|
|
62
|
+
config: object,
|
|
63
|
+
options: unknown,
|
|
64
|
+
deps: RegisterDeps = {},
|
|
65
|
+
) {
|
|
66
|
+
const configWithSkills = config as ConfigWithSkills;
|
|
67
|
+
const skillsRoot = deps.skillsRoot ?? defaultSkillsRoot();
|
|
68
|
+
const exists = deps.exists ?? fs.existsSync;
|
|
69
|
+
const enabled = resolveEnabledSupabaseSkills(options, deps);
|
|
70
|
+
|
|
71
|
+
configWithSkills.skills = configWithSkills.skills ?? {};
|
|
72
|
+
configWithSkills.skills.paths = configWithSkills.skills.paths ?? [];
|
|
73
|
+
|
|
74
|
+
for (const skill of enabled) {
|
|
75
|
+
const skillPath = path.join(skillsRoot, skill);
|
|
76
|
+
if (!exists(skillPath)) {
|
|
77
|
+
deps.warn?.("bundled Supabase skill directory not found", { skill, path: skillPath });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!configWithSkills.skills.paths.includes(skillPath)) {
|
|
81
|
+
configWithSkills.skills.paths.push(skillPath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/tui/commands.ts
CHANGED
package/src/tui/dialog.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
2
|
-
import {
|
|
2
|
+
import { RGBA, SyntaxStyle, TextAttributes } from "@opentui/core";
|
|
3
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
4
|
+
import type { JSX } from "solid-js";
|
|
3
5
|
|
|
4
6
|
import { formatAuthError } from "../shared/auth-errors.ts";
|
|
5
7
|
import type { SupabaseLogger } from "../shared/log.ts";
|
|
@@ -13,9 +15,24 @@ type SupabaseDialogProps = {
|
|
|
13
15
|
closed: boolean;
|
|
14
16
|
dismissed?: boolean;
|
|
15
17
|
preflightPromise?: Promise<void>;
|
|
18
|
+
onboardingPromptSent?: boolean;
|
|
19
|
+
chatSessionID?: string;
|
|
16
20
|
};
|
|
17
21
|
};
|
|
18
22
|
|
|
23
|
+
const ONBOARDING_MESSAGE = `Supabase is connected.
|
|
24
|
+
|
|
25
|
+
You can ask me about:
|
|
26
|
+
- your organizations and projects
|
|
27
|
+
- API keys for a project
|
|
28
|
+
- available database regions
|
|
29
|
+
- creating a new project
|
|
30
|
+
|
|
31
|
+
Try this:
|
|
32
|
+
list my Supabase projects`;
|
|
33
|
+
|
|
34
|
+
const onboardedSessionIDsByApi = new WeakMap<TuiPluginApi, Set<string>>();
|
|
35
|
+
|
|
19
36
|
type OAuthState =
|
|
20
37
|
| { type: "checking_auth" }
|
|
21
38
|
| { type: "idle" }
|
|
@@ -43,13 +60,115 @@ type AuthFlowContext = {
|
|
|
43
60
|
api: TuiPluginApi;
|
|
44
61
|
logger: SupabaseLogger;
|
|
45
62
|
setState: (state: OAuthState) => void;
|
|
46
|
-
onSuccess: () => void
|
|
63
|
+
onSuccess: () => void | Promise<void>;
|
|
47
64
|
};
|
|
48
65
|
|
|
66
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
67
|
+
|
|
68
|
+
const FALLBACK_THEME = {
|
|
69
|
+
primary: RGBA.fromHex("#347d95"),
|
|
70
|
+
selectedListItemText: RGBA.fromHex("#ffffff"),
|
|
71
|
+
text: RGBA.fromHex("#f8f5ea"),
|
|
72
|
+
textMuted: RGBA.fromHex("#9f97aa"),
|
|
73
|
+
backgroundPanel: RGBA.fromHex("#f8f5ea"),
|
|
74
|
+
markdownText: RGBA.fromHex("#5f5875"),
|
|
75
|
+
markdownHeading: RGBA.fromHex("#5f5875"),
|
|
76
|
+
markdownLink: RGBA.fromHex("#347d95"),
|
|
77
|
+
markdownStrong: RGBA.fromHex("#5f5875"),
|
|
78
|
+
markdownEmph: RGBA.fromHex("#8a6f00"),
|
|
79
|
+
markdownCode: RGBA.fromHex("#2e7d32"),
|
|
80
|
+
markdownListItem: RGBA.fromHex("#347d95"),
|
|
81
|
+
markdownBlockQuote: RGBA.fromHex("#8a6f00"),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type DialogTheme = typeof FALLBACK_THEME;
|
|
85
|
+
|
|
49
86
|
function getErrorMessage(error: unknown) {
|
|
50
87
|
return error instanceof Error ? error.message : String(error);
|
|
51
88
|
}
|
|
52
89
|
|
|
90
|
+
function getDialogTheme(api: TuiPluginApi): DialogTheme {
|
|
91
|
+
return {
|
|
92
|
+
...FALLBACK_THEME,
|
|
93
|
+
...((api as { theme?: { current?: Partial<DialogTheme> } }).theme?.current ?? {}),
|
|
94
|
+
} as DialogTheme;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createMarkdownSyntax(theme: DialogTheme) {
|
|
98
|
+
return SyntaxStyle.fromTheme([
|
|
99
|
+
{ scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },
|
|
100
|
+
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
|
|
101
|
+
{ scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
|
|
102
|
+
{ scope: ["markup.raw", "markup.raw.block", "markup.raw.inline"], style: { foreground: theme.markdownCode } },
|
|
103
|
+
{ scope: ["markup.link", "markup.link.url"], style: { foreground: theme.markdownLink, underline: true } },
|
|
104
|
+
{ scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
|
|
105
|
+
{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
|
|
106
|
+
{ scope: ["conceal"], style: { foreground: theme.textMuted } },
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function SpinnerLabel(props: { text: string; color: DialogTheme["textMuted"] }) {
|
|
111
|
+
const [frame, setFrame] = createSignal(0);
|
|
112
|
+
const interval = setInterval(() => {
|
|
113
|
+
setFrame((index) => (index + 1) % SPINNER_FRAMES.length);
|
|
114
|
+
}, 80).unref();
|
|
115
|
+
|
|
116
|
+
onCleanup(() => clearInterval(interval));
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<box flexDirection="row" gap={1}>
|
|
120
|
+
<text fg={props.color}>{SPINNER_FRAMES[frame()]}</text>
|
|
121
|
+
<text fg={props.color}>{props.text}</text>
|
|
122
|
+
</box>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function SupabaseSpinnerDialog(props: {
|
|
127
|
+
api: TuiPluginApi;
|
|
128
|
+
title: string;
|
|
129
|
+
status: string;
|
|
130
|
+
body?: string;
|
|
131
|
+
dismissible?: boolean;
|
|
132
|
+
size?: "medium" | "large" | "xlarge";
|
|
133
|
+
onClose: () => void;
|
|
134
|
+
}): JSX.Element {
|
|
135
|
+
const theme = getDialogTheme(props.api);
|
|
136
|
+
const syntax = createMarkdownSyntax(theme);
|
|
137
|
+
props.api.ui.dialog.setSize(props.size ?? "medium");
|
|
138
|
+
|
|
139
|
+
return Object.assign(() => (
|
|
140
|
+
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
|
141
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
142
|
+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
|
143
|
+
{props.title}
|
|
144
|
+
</text>
|
|
145
|
+
{props.dismissible ? (
|
|
146
|
+
<text fg={theme.textMuted} onMouseUp={props.onClose}>
|
|
147
|
+
esc
|
|
148
|
+
</text>
|
|
149
|
+
) : (
|
|
150
|
+
<text fg={theme.textMuted}> </text>
|
|
151
|
+
)}
|
|
152
|
+
</box>
|
|
153
|
+
<box paddingTop={1} paddingBottom={props.body ? 0 : 1}>
|
|
154
|
+
<SpinnerLabel text={props.status} color={theme.textMuted} />
|
|
155
|
+
</box>
|
|
156
|
+
{props.body ? (
|
|
157
|
+
<box paddingBottom={props.dismissible ? 0 : 1}>
|
|
158
|
+
<markdown content={props.body} syntaxStyle={syntax} fg={theme.markdownText} bg={theme.backgroundPanel} />
|
|
159
|
+
</box>
|
|
160
|
+
) : undefined}
|
|
161
|
+
{props.dismissible ? (
|
|
162
|
+
<box flexDirection="row" justifyContent="flex-end" paddingTop={1}>
|
|
163
|
+
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={props.onClose}>
|
|
164
|
+
<text fg={theme.selectedListItemText}>Dismiss</text>
|
|
165
|
+
</box>
|
|
166
|
+
</box>
|
|
167
|
+
) : undefined}
|
|
168
|
+
</box>
|
|
169
|
+
), { onClose: props.dismissible ? props.onClose : () => undefined });
|
|
170
|
+
}
|
|
171
|
+
|
|
53
172
|
function parseAuthStatus(instructions: string): AuthStatus {
|
|
54
173
|
const parsed = JSON.parse(instructions) as Partial<AuthStatus>;
|
|
55
174
|
if (
|
|
@@ -74,6 +193,71 @@ async function openBrowser(url: string, logger: SupabaseLogger) {
|
|
|
74
193
|
}
|
|
75
194
|
}
|
|
76
195
|
|
|
196
|
+
async function ensureChatSession(api: TuiPluginApi) {
|
|
197
|
+
const currentRoute = api.route.current;
|
|
198
|
+
let sessionID =
|
|
199
|
+
currentRoute.name === "session" ? (currentRoute.params as { sessionID?: string } | undefined)?.sessionID : undefined;
|
|
200
|
+
|
|
201
|
+
if (!sessionID && currentRoute.name === "home") {
|
|
202
|
+
const response = await api.client.session.create({});
|
|
203
|
+
sessionID = (response.data as { id?: string } | undefined)?.id;
|
|
204
|
+
if (sessionID) {
|
|
205
|
+
api.route.navigate("session", { sessionID });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return sessionID;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function injectOnboardingPrompt(
|
|
213
|
+
api: TuiPluginApi,
|
|
214
|
+
logger: SupabaseLogger,
|
|
215
|
+
lifecycle: NonNullable<SupabaseDialogProps["lifecycle"]>,
|
|
216
|
+
) {
|
|
217
|
+
if (lifecycle.onboardingPromptSent) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!lifecycle.chatSessionID) {
|
|
222
|
+
await logger.warn("supabase onboarding prompt skipped", {
|
|
223
|
+
reason: "missing_session",
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const sessionID = lifecycle.chatSessionID;
|
|
229
|
+
const onboardedSessionIDs = onboardedSessionIDsByApi.get(api) ?? new Set<string>();
|
|
230
|
+
onboardedSessionIDsByApi.set(api, onboardedSessionIDs);
|
|
231
|
+
|
|
232
|
+
if (onboardedSessionIDs.has(sessionID)) {
|
|
233
|
+
lifecycle.onboardingPromptSent = true;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
lifecycle.onboardingPromptSent = true;
|
|
238
|
+
onboardedSessionIDs.add(sessionID);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await api.client.session.promptAsync({
|
|
242
|
+
sessionID,
|
|
243
|
+
noReply: true,
|
|
244
|
+
parts: [
|
|
245
|
+
{
|
|
246
|
+
type: "text",
|
|
247
|
+
text: ONBOARDING_MESSAGE,
|
|
248
|
+
ignored: true,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
lifecycle.onboardingPromptSent = false;
|
|
254
|
+
onboardedSessionIDs.delete(sessionID);
|
|
255
|
+
await logger.warn("supabase onboarding prompt failed", {
|
|
256
|
+
message: getErrorMessage(error),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
77
261
|
export async function runAuthFlow(context: AuthFlowContext) {
|
|
78
262
|
let authURL: string | undefined;
|
|
79
263
|
let completed = false;
|
|
@@ -145,7 +329,7 @@ export async function runAuthFlow(context: AuthFlowContext) {
|
|
|
145
329
|
|
|
146
330
|
if (completed) {
|
|
147
331
|
try {
|
|
148
|
-
context.onSuccess();
|
|
332
|
+
await context.onSuccess();
|
|
149
333
|
} catch (error) {
|
|
150
334
|
await context.logger.error("supabase auth success handler failed", {
|
|
151
335
|
message: getErrorMessage(error),
|
|
@@ -227,7 +411,6 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
227
411
|
|
|
228
412
|
if (nextState.type === "success") {
|
|
229
413
|
if (lifecycle.dismissed) {
|
|
230
|
-
// User dismissed waiting dialog; stay silent
|
|
231
414
|
return;
|
|
232
415
|
}
|
|
233
416
|
props.api.ui.dialog.replace(() =>
|
|
@@ -249,15 +432,29 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
249
432
|
);
|
|
250
433
|
};
|
|
251
434
|
|
|
252
|
-
const startOAuth = () =>
|
|
253
|
-
|
|
435
|
+
const startOAuth = async () => {
|
|
436
|
+
lifecycle.dismissed = false;
|
|
437
|
+
if (!lifecycle.chatSessionID) {
|
|
438
|
+
lifecycle.chatSessionID = await ensureChatSession(props.api);
|
|
439
|
+
}
|
|
440
|
+
return runAuthFlow({
|
|
254
441
|
api: props.api,
|
|
255
442
|
logger: props.logger,
|
|
256
443
|
setState,
|
|
257
444
|
onSuccess: () => {
|
|
258
|
-
|
|
445
|
+
if (lifecycle.dismissed) {
|
|
446
|
+
props.api.ui.toast({ message: "Supabase connected" });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (lifecycle.closed) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return injectOnboardingPrompt(props.api, props.logger, lifecycle);
|
|
259
455
|
},
|
|
260
456
|
});
|
|
457
|
+
};
|
|
261
458
|
|
|
262
459
|
const retryPreflight = () => {
|
|
263
460
|
if (lifecycle.preflightPromise) {
|
|
@@ -282,6 +479,7 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
282
479
|
method: 1,
|
|
283
480
|
inputs: { action: "disconnect" },
|
|
284
481
|
});
|
|
482
|
+
props.api.ui.toast({ message: "Disconnected from Supabase" });
|
|
285
483
|
closeDialog();
|
|
286
484
|
} catch (error) {
|
|
287
485
|
await props.logger.warn("supabase disconnect failed", {
|
|
@@ -304,18 +502,19 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
304
502
|
void retryPreflight();
|
|
305
503
|
});
|
|
306
504
|
|
|
307
|
-
return
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
505
|
+
return SupabaseSpinnerDialog({
|
|
506
|
+
api: props.api,
|
|
507
|
+
title: "Connect to Supabase",
|
|
508
|
+
status: "Checking Supabase connection...",
|
|
509
|
+
body: "No action needed. This should only take a few seconds.",
|
|
510
|
+
onClose: () => undefined,
|
|
311
511
|
});
|
|
312
512
|
}
|
|
313
513
|
|
|
314
514
|
if (currentState.type === "idle") {
|
|
315
515
|
return props.api.ui.DialogConfirm({
|
|
316
|
-
title: "Connect Supabase",
|
|
317
|
-
message:
|
|
318
|
-
"This will open a browser window to authorize OpenCode to access your Supabase account. Continue?",
|
|
516
|
+
title: "Connect your Supabase account",
|
|
517
|
+
message: "Open your browser to authorize OpenCode to access your Supabase account.",
|
|
319
518
|
onConfirm: startOAuth,
|
|
320
519
|
onCancel: closeDialog,
|
|
321
520
|
});
|
|
@@ -323,25 +522,36 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
323
522
|
|
|
324
523
|
if (currentState.type === "authorizing") {
|
|
325
524
|
if (!currentState.url) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
525
|
+
return SupabaseSpinnerDialog({
|
|
526
|
+
api: props.api,
|
|
527
|
+
title: "Connect to Supabase",
|
|
528
|
+
status: "Starting authorization...",
|
|
529
|
+
body: "Opening your browser. You can close this dialog; auth completes only after browser approval.",
|
|
530
|
+
dismissible: true,
|
|
531
|
+
onClose: () => closeDialog(true),
|
|
532
|
+
});
|
|
331
533
|
}
|
|
332
534
|
|
|
333
|
-
return
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
535
|
+
return SupabaseSpinnerDialog({
|
|
536
|
+
api: props.api,
|
|
537
|
+
title: "Connect to Supabase",
|
|
538
|
+
status: "Waiting for browser authorization...",
|
|
539
|
+
body: `Complete authorization in your browser.\n\nIf the browser did not open, visit:\n${currentState.url}\n\nYou can close this dialog; auth completes only after browser approval.`,
|
|
540
|
+
dismissible: true,
|
|
541
|
+
size: "large",
|
|
542
|
+
onClose: () => closeDialog(true),
|
|
337
543
|
});
|
|
338
544
|
}
|
|
339
545
|
|
|
340
546
|
if (currentState.type === "waiting_callback") {
|
|
341
|
-
return
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
547
|
+
return SupabaseSpinnerDialog({
|
|
548
|
+
api: props.api,
|
|
549
|
+
title: "Connect to Supabase",
|
|
550
|
+
status: "Waiting for browser authorization...",
|
|
551
|
+
body: `Complete authorization in your browser.\n\nIf the browser did not open, visit:\n${currentState.url}\n\nYou can close this dialog; auth completes only after browser approval.`,
|
|
552
|
+
dismissible: true,
|
|
553
|
+
size: "large",
|
|
554
|
+
onClose: () => closeDialog(true),
|
|
345
555
|
});
|
|
346
556
|
}
|
|
347
557
|
|
|
@@ -360,9 +570,15 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
360
570
|
|
|
361
571
|
if (currentState.type === "already_connected") {
|
|
362
572
|
return props.api.ui.DialogConfirm({
|
|
363
|
-
title: "
|
|
364
|
-
message: "Your
|
|
365
|
-
onConfirm:
|
|
573
|
+
title: "You're all set",
|
|
574
|
+
message: "Your Supabase account is connected and ready to go.\n\nClose this dialog to continue, or disconnect to sign out.",
|
|
575
|
+
onConfirm: async () => {
|
|
576
|
+
if (!lifecycle.chatSessionID) {
|
|
577
|
+
lifecycle.chatSessionID = await ensureChatSession(props.api);
|
|
578
|
+
}
|
|
579
|
+
await injectOnboardingPrompt(props.api, props.logger, lifecycle);
|
|
580
|
+
closeDialog();
|
|
581
|
+
},
|
|
366
582
|
onCancel: disconnect,
|
|
367
583
|
label: "Disconnect",
|
|
368
584
|
} as import("./opencode-runtime-extensions.ts").DialogConfirmWithLabel);
|
|
@@ -377,22 +593,9 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
377
593
|
});
|
|
378
594
|
}
|
|
379
595
|
|
|
380
|
-
return props.api.ui.
|
|
596
|
+
return props.api.ui.DialogAlert({
|
|
381
597
|
title: "Connected to Supabase",
|
|
382
|
-
message:
|
|
383
|
-
|
|
384
|
-
onConfirm: async () => {
|
|
385
|
-
try {
|
|
386
|
-
await props.api.client.tui.appendPrompt({
|
|
387
|
-
text: "list my Supabase projects",
|
|
388
|
-
});
|
|
389
|
-
} catch (error) {
|
|
390
|
-
await props.logger.warn("supabase append prompt failed", {
|
|
391
|
-
message: getErrorMessage(error),
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
closeDialog();
|
|
395
|
-
},
|
|
396
|
-
onCancel: closeDialog,
|
|
598
|
+
message: "Your account is ready. Close this dialog and ask me to list your Supabase projects.",
|
|
599
|
+
onConfirm: closeDialog,
|
|
397
600
|
});
|
|
398
601
|
}
|