From 5feb0073eafec4556774c19401c3d0c9173fa2d8 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 14:13:09 +0000 Subject: [PATCH 1/3] fix: expert-cd resolve or create draft scope automatically Use API client to find existing draft via expertDrafts.list, or create one via applications.list + expertDrafts.create if none exists. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/expert-cd.yml | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/expert-cd.yml b/.github/workflows/expert-cd.yml index 28ac6ea2..7f120825 100644 --- a/.github/workflows/expert-cd.yml +++ b/.github/workflows/expert-cd.yml @@ -44,18 +44,30 @@ jobs: echo "should-publish=true" >> "$GITHUB_OUTPUT" fi - - name: Resolve draft ID + - name: Resolve or create draft scope if: steps.check.outputs.should-publish == 'true' id: draft + env: + PERSTACK_API_KEY: ${{ secrets.PERSTACK_PRODUCTION_API_KEY }} run: | - OUTPUT=$(bun ./apps/perstack/bin/cli.ts expert list --filter ${{ matrix.definition.name }} \ - --api-key ${{ secrets.PERSTACK_PRODUCTION_API_KEY }}) - echo "$OUTPUT" - DRAFT_ID=$(echo "$OUTPUT" | grep "ID:" | head -1 | awk '{print $2}') - if [ -z "$DRAFT_ID" ]; then - echo "::error::Draft scope not found for ${{ matrix.definition.name }}" - exit 1 - fi + DRAFT_ID=$(bun -e " + import { createApiClient } from '@perstack/api-client'; + const client = createApiClient({ apiKey: process.env.PERSTACK_API_KEY }); + const list = await client.expertDrafts.list({ filter: '${{ matrix.definition.name }}' }); + if (list.ok && list.data.data.length > 0) { + console.log(list.data.data[0].id); + } else { + const apps = await client.applications.list(); + if (!apps.ok) throw new Error('Failed to list applications: ' + apps.error.message); + const appId = apps.data.data.applications[0].id; + const create = await client.expertDrafts.create({ + scopeName: '${{ matrix.definition.name }}', + applicationId: appId, + }); + if (!create.ok) throw new Error('Failed to create draft: ' + create.error.message); + console.log(create.data.data.id); + } + ") echo "draft-id=$DRAFT_ID" >> "$GITHUB_OUTPUT" echo "Resolved draft ID: $DRAFT_ID" From 821c0b7f3002a7db53a59f6d6f8801a0beefb26f Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 14:27:46 +0000 Subject: [PATCH 2/3] refactor: rename draftId to draftScopeId across codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameter name `draftId` was misleading — it refers to a draft scope ID (a reusable container), not a draft ref ID (an ephemeral snapshot created by each push). Rename to `draftScopeId` to prevent confusion between the two concepts. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/expert-cd.yml | 10 ++++----- apps/perstack/bin/cli.ts | 30 +++++++++++++++---------- docs/references/cli.md | 22 +++++++++--------- e2e/studio/lifecycle.test.ts | 20 ++++++++--------- packages/studio/src/draft-handlers.ts | 14 ++++++------ packages/studio/src/version-handlers.ts | 4 ++-- 6 files changed, 53 insertions(+), 47 deletions(-) diff --git a/.github/workflows/expert-cd.yml b/.github/workflows/expert-cd.yml index 7f120825..8e603b52 100644 --- a/.github/workflows/expert-cd.yml +++ b/.github/workflows/expert-cd.yml @@ -50,7 +50,7 @@ jobs: env: PERSTACK_API_KEY: ${{ secrets.PERSTACK_PRODUCTION_API_KEY }} run: | - DRAFT_ID=$(bun -e " + DRAFT_SCOPE_ID=$(bun -e " import { createApiClient } from '@perstack/api-client'; const client = createApiClient({ apiKey: process.env.PERSTACK_API_KEY }); const list = await client.expertDrafts.list({ filter: '${{ matrix.definition.name }}' }); @@ -68,14 +68,14 @@ jobs: console.log(create.data.data.id); } ") - echo "draft-id=$DRAFT_ID" >> "$GITHUB_OUTPUT" - echo "Resolved draft ID: $DRAFT_ID" + echo "draft-scope-id=$DRAFT_SCOPE_ID" >> "$GITHUB_OUTPUT" + echo "Resolved draft scope ID: $DRAFT_SCOPE_ID" - name: Push to draft if: steps.check.outputs.should-publish == 'true' id: push run: | - OUTPUT=$(bun ./apps/perstack/bin/cli.ts expert push ${{ steps.draft.outputs.draft-id }} \ + OUTPUT=$(bun ./apps/perstack/bin/cli.ts expert push ${{ steps.draft.outputs.draft-scope-id }} \ --config ${{ matrix.definition.path }}/perstack.toml \ --api-key ${{ secrets.PERSTACK_PRODUCTION_API_KEY }}) echo "$OUTPUT" @@ -92,7 +92,7 @@ jobs: if: steps.check.outputs.should-publish == 'true' run: | bun ./apps/perstack/bin/cli.ts expert version \ - ${{ steps.draft.outputs.draft-id }} \ + ${{ steps.draft.outputs.draft-scope-id }} \ ${{ steps.push.outputs.ref-id }} \ ${{ steps.version.outputs.version }} \ --tag latest \ diff --git a/apps/perstack/bin/cli.ts b/apps/perstack/bin/cli.ts index ebf68665..689e6ca7 100755 --- a/apps/perstack/bin/cli.ts +++ b/apps/perstack/bin/cli.ts @@ -204,44 +204,50 @@ expertCmd expertCmd .command("delete") .description("Delete a draft scope") - .argument("", "Draft scope ID") - .action(async function (this: InstanceType, draftId) { + .argument("", "Draft scope ID") + .action(async function (this: InstanceType, draftScopeId) { const parent = getParentOptions(this) - await expertDeleteHandler(draftId, parent) + await expertDeleteHandler(draftScopeId, parent) }) expertCmd .command("push") .description("Push local expert definitions to a draft ref") - .argument("", "Draft scope ID") + .argument("", "Draft scope ID") .option("--config ", "Path to perstack.toml config file") - .action(async function (this: InstanceType, draftId, options) { + .action(async function (this: InstanceType, draftScopeId, options) { const parent = getParentOptions(this) - await expertPushHandler(draftId, { ...parent, ...options }) + await expertPushHandler(draftScopeId, { ...parent, ...options }) }) expertCmd .command("refs") .description("List draft refs for a draft scope") - .argument("", "Draft scope ID") + .argument("", "Draft scope ID") .option("--take ", "Limit results", Number.parseInt) .option("--skip ", "Offset", Number.parseInt) - .action(async function (this: InstanceType, draftId, options) { + .action(async function (this: InstanceType, draftScopeId, options) { const parent = getParentOptions(this) - await expertRefsHandler(draftId, { ...parent, ...options }) + await expertRefsHandler(draftScopeId, { ...parent, ...options }) }) expertCmd .command("version") .description("Assign a version to a draft ref") - .argument("", "Draft scope ID") + .argument("", "Draft scope ID") .argument("", "Draft ref ID") .argument("", "Semantic version (e.g., 1.0.0)") .option("--tag ", "Version tag (e.g., latest)") .option("--readme ", "Path to README file") - .action(async function (this: InstanceType, draftId, refId, version, options) { + .action(async function ( + this: InstanceType, + draftScopeId, + refId, + version, + options, + ) { const parent = getParentOptions(this) - await expertVersionHandler(draftId, refId, version, { ...parent, ...options }) + await expertVersionHandler(draftScopeId, refId, version, { ...parent, ...options }) }) expertCmd diff --git a/docs/references/cli.md b/docs/references/cli.md index ef95ef84..bd9d5bc4 100644 --- a/docs/references/cli.md +++ b/docs/references/cli.md @@ -258,24 +258,24 @@ perstack expert create --app Delete a draft scope. ```bash -perstack expert delete +perstack expert delete ``` | Argument | Required | Description | | ----------- | -------- | -------------- | -| `` | Yes | Draft scope ID | +| `` | Yes | Draft scope ID | ### `expert push` Push local expert definitions to a draft ref. ```bash -perstack expert push [options] +perstack expert push [options] ``` | Argument | Required | Description | | ----------- | -------- | -------------- | -| `` | Yes | Draft scope ID | +| `` | Yes | Draft scope ID | | Option | Description | | ----------------- | ----------------------- | @@ -288,12 +288,12 @@ Reads experts from `perstack.toml` and creates a new draft ref. List draft refs for a draft scope. ```bash -perstack expert refs [options] +perstack expert refs [options] ``` | Argument | Required | Description | | ----------- | -------- | -------------- | -| `` | Yes | Draft scope ID | +| `` | Yes | Draft scope ID | | Option | Description | | ------------ | ------------- | @@ -305,12 +305,12 @@ perstack expert refs [options] Assign a semantic version to a draft ref. ```bash -perstack expert version [options] +perstack expert version [options] ``` | Argument | Required | Description | | ----------- | -------- | ---------------------------- | -| `` | Yes | Draft scope ID | +| `` | Yes | Draft scope ID | | `` | Yes | Draft ref ID | | `` | Yes | Semantic version (e.g., `1.0.0`) | @@ -407,13 +407,13 @@ perstack install # Expert lifecycle perstack expert create my-expert --app app_123 -perstack expert push clxxx --config ./perstack.toml -perstack expert version clxxx rfxxx 1.0.0 --tag latest +perstack expert push --config ./perstack.toml +perstack expert version 1.0.0 --tag latest perstack expert versions my-expert perstack expert publish my-expert perstack expert yank my-expert@1.0.0 perstack expert unpublish my-expert -perstack expert delete clxxx +perstack expert delete # View execution logs perstack log diff --git a/e2e/studio/lifecycle.test.ts b/e2e/studio/lifecycle.test.ts index 0e1b161f..d35d056d 100644 --- a/e2e/studio/lifecycle.test.ts +++ b/e2e/studio/lifecycle.test.ts @@ -15,7 +15,7 @@ describe("Studio Lifecycle", () => { const scopeName = `e2e-studio-test-${Date.now()}` let applicationId: string - let draftId: string + let draftScopeId: string let refId: string beforeAll(async () => { @@ -28,8 +28,8 @@ describe("Studio Lifecycle", () => { }) afterAll(async () => { - if (draftId) { - await client.expertDrafts.delete(draftId).catch(() => {}) + if (draftScopeId) { + await client.expertDrafts.delete(draftScopeId).catch(() => {}) } }) @@ -57,7 +57,7 @@ describe("Studio Lifecycle", () => { if (!result.ok) throw new Error(`Failed to create draft: ${result.error.message}`) expect(result.data.data).toHaveProperty("id") expect(result.data.data.name).toBe(scopeName) - draftId = result.data.data.id + draftScopeId = result.data.data.id }, API_TIMEOUT, ) @@ -65,7 +65,7 @@ describe("Studio Lifecycle", () => { it( "should push experts (create ref)", async () => { - expect(draftId).toBeDefined() + expect(draftScopeId).toBeDefined() const experts = [ { key: scopeName, @@ -88,7 +88,7 @@ describe("Studio Lifecycle", () => { tags: [], }, ] - const result = await client.expertDrafts.refs.create(draftId, { experts }) + const result = await client.expertDrafts.refs.create(draftScopeId, { experts }) expect(result.ok).toBe(true) if (!result.ok) throw new Error(`Failed to push: ${result.error.message}`) expect(result.data.data.draftRef).toHaveProperty("id") @@ -100,8 +100,8 @@ describe("Studio Lifecycle", () => { it( "should list refs", async () => { - expect(draftId).toBeDefined() - const result = await client.expertDrafts.refs.list(draftId) + expect(draftScopeId).toBeDefined() + const result = await client.expertDrafts.refs.list(draftScopeId) expect(result.ok).toBe(true) if (!result.ok) return expect(result.data.data.length).toBeGreaterThan(0) @@ -114,9 +114,9 @@ describe("Studio Lifecycle", () => { it( "should assign version", async () => { - expect(draftId).toBeDefined() + expect(draftScopeId).toBeDefined() expect(refId).toBeDefined() - const result = await client.expertDrafts.refs.assignVersion(draftId, refId, { + const result = await client.expertDrafts.refs.assignVersion(draftScopeId, refId, { version: "0.0.1-e2e-test", }) expect(result.ok).toBe(true) diff --git a/packages/studio/src/draft-handlers.ts b/packages/studio/src/draft-handlers.ts index c39f83e6..2549c02a 100644 --- a/packages/studio/src/draft-handlers.ts +++ b/packages/studio/src/draft-handlers.ts @@ -65,14 +65,14 @@ export interface ExpertDeleteOptions { baseUrl?: string } -export async function expertDeleteHandler(draftId: string, options: ExpertDeleteOptions) { +export async function expertDeleteHandler(draftScopeId: string, options: ExpertDeleteOptions) { const apiKey = resolveApiKey(options.apiKey) const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) - const result = await client.expertDrafts.delete(draftId) + const result = await client.expertDrafts.delete(draftScopeId) if (!result.ok) { throw new PerstackError(`Failed to delete draft: ${result.error.message}`) } - console.log(`Draft scope deleted: ${draftId}`) + console.log(`Draft scope deleted: ${draftScopeId}`) } export interface ExpertPushOptions { @@ -81,7 +81,7 @@ export interface ExpertPushOptions { config?: string } -export async function expertPushHandler(draftId: string, options: ExpertPushOptions) { +export async function expertPushHandler(draftScopeId: string, options: ExpertPushOptions) { const apiKey = resolveApiKey(options.apiKey) const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) const perstackConfig = await getPerstackConfig(options.config) @@ -94,7 +94,7 @@ export async function expertPushHandler(draftId: string, options: ExpertPushOpti // Expert type from @perstack/core has slightly looser types than the API expects // (e.g., skill.description is optional in core but required in API) // The runtime values are compatible since expertSchema enforces defaults - const result = await client.expertDrafts.refs.create(draftId, { + const result = await client.expertDrafts.refs.create(draftScopeId, { experts: experts as Parameters[1]["experts"], }) if (!result.ok) { @@ -114,10 +114,10 @@ export interface ExpertRefsOptions { skip?: number } -export async function expertRefsHandler(draftId: string, options: ExpertRefsOptions) { +export async function expertRefsHandler(draftScopeId: string, options: ExpertRefsOptions) { const apiKey = resolveApiKey(options.apiKey) const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) - const result = await client.expertDrafts.refs.list(draftId, { + const result = await client.expertDrafts.refs.list(draftScopeId, { take: options.take, skip: options.skip, }) diff --git a/packages/studio/src/version-handlers.ts b/packages/studio/src/version-handlers.ts index 70e6ae1d..402ce8d8 100644 --- a/packages/studio/src/version-handlers.ts +++ b/packages/studio/src/version-handlers.ts @@ -10,7 +10,7 @@ export interface ExpertVersionOptions { } export async function expertVersionHandler( - draftId: string, + draftScopeId: string, refId: string, version: string, options: ExpertVersionOptions, @@ -21,7 +21,7 @@ export async function expertVersionHandler( if (options.readme) { readme = await readFile(options.readme, "utf-8") } - const result = await client.expertDrafts.refs.assignVersion(draftId, refId, { + const result = await client.expertDrafts.refs.assignVersion(draftScopeId, refId, { version, tag: options.tag, readme, From a08eeda27dbfe2c659125e9bd745feb02af34b69 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 14:29:06 +0000 Subject: [PATCH 3/3] chore: add changeset for draftScopeId rename Co-Authored-By: Claude Opus 4.6 --- .changeset/rename-draft-scope-id.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/rename-draft-scope-id.md diff --git a/.changeset/rename-draft-scope-id.md b/.changeset/rename-draft-scope-id.md new file mode 100644 index 00000000..8d9381ae --- /dev/null +++ b/.changeset/rename-draft-scope-id.md @@ -0,0 +1,6 @@ +--- +"@perstack/studio": patch +"perstack": patch +--- + +Rename `draftId` to `draftScopeId` in CLI arguments, handler parameters, and documentation to clarify the distinction between draft scope IDs and draft ref IDs.