feat(scripts): group-owned script admin API + pic scripts … --group

Phase 4-lite C2. Lets a group own scripts (templates inherited by descendant
apps), with the create/list/manage surface — but not yet the inherited
resolution (binding + runtime), which is C3.

Capabilities: add `GroupScriptsRead` (viewer+ on the group → script:read) and
`GroupScriptsWrite` (editor+ → script:write), mirroring the group-vars tier and
resolved through the same group-ancestor walk.

API:
  * New `group_scripts_api`: `POST/GET /groups/{id}/scripts` — create a
    group-owned endpoint script and list a group's own (non-inherited) rows.
    Phase 4-lite is endpoint-only and self-contained: `kind=module` and any
    `import` are rejected (group modules + the lexical resolver are Phase 4b).
    Owner resolved first (slug-or-uuid); capability bound to the resolved id.
  * The by-id `/scripts/{id}` get/update/delete/logs handlers are now
    owner-polymorphic via a `script_cap` helper: app-owned scripts gate on
    `App*` exactly as before; group-owned on `GroupScripts*`. This is what
    makes `deploy --group` idempotent (update reuses the by-id PUT) and lets a
    group script be deleted by id. (C1 had these fail closed for groups.)

Repo: `list_for_group(group_id)` (the group's own rows).

CLI: `pic scripts ls --group <g>`, `pic scripts deploy --group <g>` (create or
update by name; `--app`/`--group` are mutually exclusive, exactly one
required). `pic scripts delete <id>` already works for group scripts via the
owner-polymorphic by-id route.

Live-validated against the dev DB: create → update (v2 via the by-id PUT) →
list → delete, plus module rejection and the polymorphic row shape
(`app_id NULL`, `group_id` set). Group scripts still can't be routed/triggered
or invoked — that lands in C3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 20:10:20 +02:00
parent 48178c5f60
commit 43ed4e8e7f
13 changed files with 491 additions and 56 deletions

View File

@@ -309,6 +309,33 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}/scripts` — a group's own
/// scripts (Phase 4). Not inherited; just the group's rows.
pub async fn group_scripts_list(&self, group_ident: &str) -> Result<Vec<Script>> {
let g = seg(group_ident);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/groups/{g}/scripts"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/groups/{id_or_slug}/scripts` — create a
/// group-owned script (Phase 4).
pub async fn group_scripts_create(
&self,
group_ident: &str,
body: &CreateGroupScriptBody<'_>,
) -> Result<Script> {
let g = seg(group_ident);
let resp = self
.request(Method::POST, &format!("/api/v1/admin/groups/{g}/scripts"))
.json(body)
.send()
.await?;
decode(resp).await
}
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
/// uses PUT despite the field-level update semantics. `cfg` carries
/// optional per-script runtime overrides (G3); unset fields are
@@ -1637,6 +1664,24 @@ pub struct CreateScriptBody<'a> {
pub sandbox: Option<ScriptSandbox>,
}
/// Phase 4: body for `POST /groups/{id}/scripts`. Like `CreateScriptBody`
/// minus `app_id` — the owning group comes from the path.
#[derive(Debug, Serialize)]
pub struct CreateGroupScriptBody<'a> {
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
pub source: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_limit_mb: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<ScriptKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ScriptSandbox>,
}
#[derive(Debug, Serialize)]
struct UpdateScriptBody<'a> {
source: &'a str,

View File

@@ -8,16 +8,33 @@ use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId;
use serde_json::Value;
use crate::client::{Client, CreateScriptBody, ScriptConfig};
use crate::client::{Client, CreateGroupScriptBody, CreateScriptBody, ScriptConfig};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
if let Some(group_ident) = group {
// Phase 4: a group's own (non-inherited) scripts. The owner column
// shows the group, not an app.
let scripts = client.group_scripts_list(group_ident).await?;
for s in scripts {
table.row([
s.id.to_string(),
format!("group:{group_ident}"),
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
}
table.print(mode);
return Ok(());
}
if let Some(ident) = app {
let app = client.apps_get(ident).await?;
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
@@ -64,7 +81,8 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
pub async fn deploy(
file: &Path,
app_ident: &str,
app_ident: Option<&str>,
group_ident: Option<&str>,
name_override: Option<&str>,
description: Option<&str>,
cfg: &ScriptConfig,
@@ -89,28 +107,61 @@ pub async fn deploy(
})?,
};
// Slug-or-id resolution: a single GET satisfies both lookups and
// gives us the canonical app_id needed for create.
let app = client.apps_get(app_ident).await?;
let existing = client.scripts_list_by_app(app_ident).await?;
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source, cfg)
.await?;
(updated, "updated")
} else {
let body = CreateScriptBody {
app_id: app.app.id,
name: &name,
description,
source: &source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
(client.scripts_create(&body).await?, "created")
// Deploy is create-or-update by name within the chosen owner. Exactly
// one owner — clap marks `--group` as conflicting with `--app`, so this
// only rejects the both-absent case.
let (script, action) = match (app_ident, group_ident) {
(Some(app_ident), None) => {
// Slug-or-id resolution: a single GET gives the canonical app_id.
let app = client.apps_get(app_ident).await?;
let existing = client.scripts_list_by_app(app_ident).await?;
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source, cfg)
.await?;
(updated, "updated")
} else {
let body = CreateScriptBody {
app_id: app.app.id,
name: &name,
description,
source: &source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
(client.scripts_create(&body).await?, "created")
}
}
(None, Some(group_ident)) => {
// Group scripts update through the owner-polymorphic by-id
// endpoint; create through the group endpoint.
let existing = client.group_scripts_list(group_ident).await?;
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source, cfg)
.await?;
(updated, "updated")
} else {
let body = CreateGroupScriptBody {
name: &name,
description,
source: &source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
(
client.group_scripts_create(group_ident, &body).await?,
"created",
)
}
}
(Some(_), Some(_)) | (None, None) => {
return Err(anyhow!("deploy requires exactly one of --app or --group"));
}
};
// Emit the script object so `--output json` callers can capture the
// id for the follow-up routes/triggers calls.

View File

@@ -529,15 +529,19 @@ enum DomainsCmd {
#[derive(Subcommand)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without, one
/// List scripts. With `--app`, scoped to one app; with `--group`, a
/// group's own scripts (Phase 4); without either, one
/// `GET /admin/scripts` for everything the caller can see.
Ls {
#[arg(long)]
app: Option<String>,
/// Phase 4: list a group's own (non-inherited) scripts.
#[arg(long, conflicts_with = "app")]
group: Option<String>,
},
/// Upload a `.rhai` file. Patches the existing script with the
/// matching name in `--app` if one exists, otherwise creates it.
/// matching name in `--app`/`--group` if one exists, otherwise creates it.
Deploy(DeployArgs),
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
@@ -551,8 +555,14 @@ enum ScriptsCmd {
#[derive(Args)]
struct DeployArgs {
file: PathBuf,
/// Owning app (slug or id). Exactly one of `--app` / `--group`.
#[arg(long)]
app: String,
app: Option<String>,
/// Phase 4: deploy a group-owned script (template inherited by
/// descendant apps) instead of an app-owned one. Exactly one of
/// `--app` / `--group`.
#[arg(long, conflicts_with = "app")]
group: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
@@ -1465,15 +1475,16 @@ async fn main() -> ExitCode {
},
} => cmds::groups::members_rm(&group, &user_id).await,
Cmd::Scripts {
cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref(), mode).await,
cmd: ScriptsCmd::Ls { app, group },
} => cmds::scripts::ls(app.as_deref(), group.as_deref(), mode).await,
Cmd::Scripts {
cmd: ScriptsCmd::Deploy(args),
} => match args.script_config() {
Ok(cfg) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.app.as_deref(),
args.group.as_deref(),
args.name.as_deref(),
args.description.as_deref(),
&cfg,
@@ -1516,7 +1527,8 @@ async fn main() -> ExitCode {
Ok(cfg) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.app.as_deref(),
args.group.as_deref(),
args.name.as_deref(),
args.description.as_deref(),
&cfg,