-
Notifications
You must be signed in to change notification settings - Fork 10
Add CLI #145
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
Add CLI #145
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { supabaseAdmin } from "@changespage/supabase/admin"; | ||
| import { PostStatus } from "@changespage/supabase/types/page"; | ||
| import { NewPostSchema } from "../../../../data/schema"; | ||
| import { withSecretKey } from "../../../../utils/withSecretKey"; | ||
| import { IPublicPost, POST_SELECT_FIELDS } from "./shared"; | ||
|
|
||
| export default withSecretKey<IPublicPost>(async (req, res, { page }) => { | ||
| const { id } = req.query; | ||
|
|
||
| if (!id || typeof id !== "string") { | ||
| return res | ||
| .status(400) | ||
| .json({ error: { statusCode: 400, message: "Missing post id" } }); | ||
| } | ||
|
|
||
| if (req.method === "GET") { | ||
| const { data, error } = await supabaseAdmin | ||
| .from("posts") | ||
| .select(POST_SELECT_FIELDS) | ||
| .eq("id", id) | ||
| .eq("page_id", page.id) | ||
| .single(); | ||
|
|
||
| if (error) { | ||
| if (error.code === "PGRST116") { | ||
| return res | ||
| .status(404) | ||
| .json({ error: { statusCode: 404, message: "Post not found" } }); | ||
| } | ||
| return res | ||
| .status(500) | ||
| .json({ error: { statusCode: 500, message: error.message } }); | ||
| } | ||
|
|
||
| return res.status(200).json(data); | ||
| } | ||
|
|
||
| if (req.method === "PATCH") { | ||
| const UPDATABLE_FIELDS = ["title", "content", "tags", "status", "publish_at", "allow_reactions", "notes"] as const; | ||
|
|
||
| const updates: Record<string, unknown> = {}; | ||
| for (const field of UPDATABLE_FIELDS) { | ||
| if (req.body[field] !== undefined) updates[field] = req.body[field]; | ||
| } | ||
|
|
||
| if (updates.status === PostStatus.published) { | ||
| updates.publication_date = new Date().toISOString(); | ||
| } | ||
arjunkomath marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (Object.keys(updates).length === 0) { | ||
| return res | ||
| .status(400) | ||
| .json({ error: { statusCode: 400, message: "No fields to update" } }); | ||
| } | ||
|
|
||
| try { | ||
| const partialSchema = NewPostSchema.pick(Object.keys(updates) as (keyof typeof NewPostSchema.fields)[]); | ||
| await partialSchema.validate(updates); | ||
| } catch (validationError: unknown) { | ||
| return res.status(400).json({ | ||
| error: { | ||
| statusCode: 400, | ||
| message: | ||
| validationError instanceof Error | ||
| ? validationError.message | ||
| : "Validation failed", | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| const { data, error } = await supabaseAdmin | ||
| .from("posts") | ||
| .update(updates) | ||
| .eq("id", id) | ||
| .eq("page_id", page.id) | ||
| .select(POST_SELECT_FIELDS) | ||
| .single(); | ||
|
|
||
| if (error) { | ||
| if (error.code === "PGRST116") { | ||
| return res | ||
| .status(404) | ||
| .json({ error: { statusCode: 404, message: "Post not found" } }); | ||
| } | ||
| return res | ||
| .status(500) | ||
| .json({ error: { statusCode: 500, message: error.message } }); | ||
| } | ||
|
|
||
| return res.status(200).json(data); | ||
| } | ||
|
|
||
| if (req.method === "DELETE") { | ||
| const { error } = await supabaseAdmin | ||
| .from("posts") | ||
| .delete() | ||
| .eq("id", id) | ||
| .eq("page_id", page.id); | ||
|
|
||
| if (error) { | ||
| return res | ||
| .status(500) | ||
| .json({ error: { statusCode: 500, message: error.message } }); | ||
| } | ||
|
|
||
| return res.status(204).end(); | ||
| } | ||
arjunkomath marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return res | ||
| .status(405) | ||
| .json({ error: { statusCode: 405, message: "Method not allowed" } }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { supabaseAdmin } from "@changespage/supabase/admin"; | ||
| import { PostStatus } from "@changespage/supabase/types/page"; | ||
| import { v4 } from "uuid"; | ||
| import { NewPostSchema } from "../../../../data/schema"; | ||
| import { withSecretKey } from "../../../../utils/withSecretKey"; | ||
| import { IPublicPost, POST_SELECT_FIELDS } from "./shared"; | ||
|
|
||
| export default withSecretKey<IPublicPost | IPublicPost[]>(async (req, res, { page }) => { | ||
| if (req.method === "GET") { | ||
| const { status, limit: rawLimit = "20", offset: rawOffset = "0" } = req.query; | ||
|
|
||
| const limit = Math.max(1, Number(rawLimit) || 20); | ||
| const offset = Math.max(0, Number(rawOffset) || 0); | ||
|
|
||
| let query = supabaseAdmin | ||
| .from("posts") | ||
| .select(POST_SELECT_FIELDS) | ||
| .eq("page_id", page.id) | ||
| .order("created_at", { ascending: false }) | ||
| .range(offset, offset + limit - 1); | ||
|
|
||
| if (status && typeof status === "string") { | ||
| if (!Object.values(PostStatus).includes(status as PostStatus)) { | ||
| return res | ||
| .status(400) | ||
| .json({ error: { statusCode: 400, message: "Invalid status" } }); | ||
| } | ||
| query = query.eq("status", status as PostStatus); | ||
| } | ||
|
|
||
| const { data, error } = await query; | ||
|
|
||
| if (error) { | ||
| return res | ||
| .status(500) | ||
| .json({ error: { statusCode: 500, message: error.message } }); | ||
| } | ||
|
|
||
| return res.status(200).json(data); | ||
arjunkomath marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| if (req.method === "POST") { | ||
| const { title, tags, content, status, publish_at, allow_reactions, notes } = | ||
| req.body; | ||
|
|
||
| const images_folder = v4(); | ||
| const publication_date = | ||
| status === PostStatus.published ? new Date().toISOString() : null; | ||
|
|
||
| const postData = { | ||
| title, | ||
| content, | ||
| tags: tags ?? [], | ||
| status: status ?? PostStatus.draft, | ||
| page_id: page.id, | ||
| user_id: page.user_id, | ||
| images_folder, | ||
| publication_date, | ||
| publish_at: publish_at ?? null, | ||
| allow_reactions: allow_reactions ?? false, | ||
| email_notified: false, | ||
| notes: notes ?? null, | ||
| }; | ||
|
|
||
| try { | ||
| await NewPostSchema.validate(postData); | ||
| } catch (validationError: unknown) { | ||
| return res.status(400).json({ | ||
| error: { | ||
| statusCode: 400, | ||
| message: | ||
| validationError instanceof Error | ||
| ? validationError.message | ||
| : "Validation failed", | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| const { data, error } = await supabaseAdmin | ||
| .from("posts") | ||
| .insert([postData]) | ||
| .select(POST_SELECT_FIELDS); | ||
|
|
||
| if (error) { | ||
| return res | ||
| .status(500) | ||
| .json({ error: { statusCode: 500, message: error.message } }); | ||
| } | ||
|
|
||
| return res.status(201).json(data[0]); | ||
arjunkomath marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| return res | ||
| .status(405) | ||
| .json({ error: { statusCode: 405, message: "Method not allowed" } }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { IPost } from "@changespage/supabase/types/page"; | ||
|
|
||
| export type IPublicPost = Omit<IPost, "user_id" | "page_id" | "images_folder" | "email_notified">; | ||
|
|
||
| export const POST_SELECT_FIELDS = | ||
| "id, title, content, tags, status, notes, allow_reactions, publish_at, publication_date, created_at, updated_at"; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,42 @@ | ||||||||||||||||||||||||||||||||||||||
| import { IErrorResponse } from "@changespage/supabase/types/api"; | ||||||||||||||||||||||||||||||||||||||
| import { IPage } from "@changespage/supabase/types/page"; | ||||||||||||||||||||||||||||||||||||||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||||||||||||||||||||||||||||||||||||||
| import { getPageByIntegrationSecret } from "./useDatabase"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| type SecretKeyHandler<T = unknown> = ( | ||||||||||||||||||||||||||||||||||||||
| req: NextApiRequest, | ||||||||||||||||||||||||||||||||||||||
| res: NextApiResponse<T | IErrorResponse>, | ||||||||||||||||||||||||||||||||||||||
| { page }: { page: IPage }, | ||||||||||||||||||||||||||||||||||||||
| ) => Promise<unknown> | void; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function withSecretKey<T = unknown>(handler: SecretKeyHandler<T>) { | ||||||||||||||||||||||||||||||||||||||
| return async ( | ||||||||||||||||||||||||||||||||||||||
| req: NextApiRequest, | ||||||||||||||||||||||||||||||||||||||
| res: NextApiResponse<T | IErrorResponse>, | ||||||||||||||||||||||||||||||||||||||
| ) => { | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| const secretKey = req.headers["page-secret-key"]; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (!secretKey) { | ||||||||||||||||||||||||||||||||||||||
| return res.status(401).json({ | ||||||||||||||||||||||||||||||||||||||
| error: { statusCode: 401, message: "Missing page-secret-key header" }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const page = await getPageByIntegrationSecret(String(secretKey)); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+18
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🛡️ Proposed fix const secretKey = req.headers["page-secret-key"];
- if (!secretKey) {
+ if (!secretKey || Array.isArray(secretKey)) {
return res.status(401).json({
error: { statusCode: 401, message: "Missing page-secret-key header" },
});
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (!page) { | ||||||||||||||||||||||||||||||||||||||
| return res.status(401).json({ | ||||||||||||||||||||||||||||||||||||||
| error: { statusCode: 401, message: "Invalid secret key" }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return handler(req, res, { page }); | ||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||
| console.error("Secret key auth error:", error); | ||||||||||||||||||||||||||||||||||||||
| return res.status(500).json({ | ||||||||||||||||||||||||||||||||||||||
| error: { statusCode: 500, message: "Internal server error" }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+35
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Catch-all handler returns If 🛡️ Proposed fix } catch (error) {
console.error("Secret key auth error:", error);
- return res.status(401).json({
- error: { statusCode: 401, message: "Invalid secret key" },
- });
+ return res.status(500).json({
+ error: { statusCode: 500, message: "Internal server error" },
+ });
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| # @changespage/cli | ||
|
|
||
| CLI for [changes.page](https://changes.page) — manage posts from the terminal. | ||
|
|
||
| ## Install | ||
|
|
||
| ```bash | ||
| npm install -g @changespage/cli | ||
| ``` | ||
|
|
||
| ## Setup | ||
|
|
||
| ```bash | ||
| chp configure | ||
| ``` | ||
|
|
||
| You'll be prompted to enter your page secret key. Find it in your page settings under **Integrations**. | ||
|
|
||
| Alternatively, use the `CHANGESPAGE_SECRET_KEY` environment variable or `--secret-key` flag. | ||
|
|
||
| ## Usage | ||
|
|
||
| ### List posts | ||
|
|
||
| ```bash | ||
| chp posts list | ||
| chp posts list --status published --limit 5 | ||
| ``` | ||
|
|
||
| ### Get a post | ||
|
|
||
| ```bash | ||
| chp posts get <id> | ||
| ``` | ||
|
|
||
| ### Create a post | ||
|
|
||
| Content is read from stdin: | ||
|
|
||
| ```bash | ||
| echo "Release notes here" | chp posts create --title "v2.0" --tags new,fix --status draft | ||
| ``` | ||
|
|
||
| Or from a file: | ||
|
|
||
| ```bash | ||
| chp posts create --title "v2.0" --tags new,fix --status published < content.md | ||
| ``` | ||
|
|
||
| ### Update a post | ||
|
|
||
| ```bash | ||
| echo "Updated content" | chp posts update <id> --title "v2.1" --tags improvement | ||
| ``` | ||
|
|
||
| Update metadata only (no stdin pipe): | ||
|
|
||
| ```bash | ||
| chp posts update <id> --status published | ||
| ``` | ||
|
|
||
| ### Delete a post | ||
|
|
||
| ```bash | ||
| chp posts delete <id> | ||
| ``` | ||
|
|
||
| ## Options | ||
|
|
||
| | Flag | Description | | ||
| |---|---| | ||
| | `--secret-key <key>` | Page secret key | | ||
| | `--pretty` | Pretty-print JSON output | | ||
|
|
||
| ### List options | ||
|
|
||
| | Flag | Description | | ||
| |---|---| | ||
| | `--status <status>` | Filter by status: `draft`, `published`, `archived` | | ||
| | `--limit <n>` | Max number of posts (default: `20`) | | ||
| | `--offset <n>` | Offset for pagination (default: `0`) | | ||
|
|
||
| ### Create / Update options | ||
|
|
||
| | Flag | Description | | ||
| |---|---| | ||
| | `--title <title>` | Post title (required for create) | | ||
| | `--tags <tags>` | Comma-separated tags: `new`, `fix`, `improvement`, `announcement`, `alert` | | ||
| | `--status <status>` | `draft`, `published`, `archived` (default: `draft`) | | ||
| | `--publish-at <date>` | ISO date for scheduled publish | | ||
| | `--allow-reactions` / `--no-allow-reactions` | Enable or disable reactions | | ||
| | `--notes <notes>` | Internal notes | | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| ## License | ||
|
|
||
| MIT | ||
Uh oh!
There was an error while loading. Please reload this page.