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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user