Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions apps/desktop/static/bundled-skills/deploy-skill/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
---
name: deploy-skill
description: "Deploy static websites to Cloudflare Pages. Use when a user wants to upload a zip bundle, publish it, and get a final live link later."
---

# deploy-skill

Deploy a zipped static website bundle to Cloudflare Pages through the remote deploy server.

Default production gateway:
- `https://deploy.nexu.io`

This skill is self-contained:
- it reads its own deploy server config from `~/.nexu/deploy-skill.json`
- it reads the Nexu cloud API key from `~/.nexu/config.json`
- it stores submitted jobs in `~/.nexu/deploy-skill-jobs.json`
- it can either submit an existing zip or render a built-in template into a zip first
- it submits the resulting zip directly to the deploy server
- it emits a follow-up async payload so the final completion message is delivered later

## Requirements

- Node.js 24+
- `baseUrl` configured in `~/.nexu/deploy-skill.json`
- a logged-in Nexu cloud account so `~/.nexu/config.json` contains `desktop.cloud.apiKey`

## Setup

The production deploy gateway for this skill is:
- `https://deploy.nexu.io`

Configure the deploy server base URL:

```bash
node scripts/deploy_skill.js setup --base-url https://deploy.nexu.io
```

Validate the setup:

```bash
node scripts/deploy_skill.js check
```

## Submit a Deploy

```bash
node scripts/deploy_skill.js submit \
--zip /absolute/path/to/site.zip \
--bot-id BOT_ID \
--chat-id CHAT_ID \
--chat-type channel \
--channel slack \
[--to DELIVERY_TARGET] \
[--thread-id THREAD_ID] \
[--account-id ACCOUNT_ID] \
[--session-key SESSION_KEY] \
[--user-id USER_ID]
```

On success, the command returns immediately and emits a `sessions_spawn` payload so the runtime can continue polling in the background.

## Submit a Template Candidate

The first built-in candidate template is `distill-campaign`.

```bash
node scripts/deploy_skill.js submit \
--template-id distill-campaign \
--content-file /absolute/path/to/content.json \
--bot-id BOT_ID \
--chat-id CHAT_ID \
--chat-type channel \
--channel slack \
[--to DELIVERY_TARGET] \
[--thread-id THREAD_ID] \
[--account-id ACCOUNT_ID] \
[--session-key SESSION_KEY] \
[--user-id USER_ID]
```

The content file must be structured JSON with:
- `title`
- `subtitle`
- `tags`
- `metrics`
- `description`
- `qaCards`
- `dialogs`
- `ctaText`
- `installText`

To restore unfinished jobs after a restart:

```bash
node scripts/deploy_skill.js recover
```

## Final Success Message

When the deploy finishes successfully, the async follow-up must tell the user exactly:

> Your website is ready, the link is {link}

## Mandatory Guard Checklist

This skill has a hard anti-hallucination rule. The model must verify each step before it can describe that step as successful.

Submit step checks:
- confirm `~/.nexu/deploy-skill.json` exists and contains a valid `baseUrl`
- confirm `~/.nexu/config.json` contains a connected Nexu cloud account with a non-empty API key
- confirm either the upload path exists and ends with `.zip`, or the template id is registered and the content file matches the template schema
- confirm submit sends `Authorization: Bearer <apiKey>` from the local Nexu config
- confirm the server response contains a real `jobId`
- confirm `taskType` is exactly `static-deploy`
- confirm `status` is exactly `queued` or `running`
- if a delivery target is available, confirm it is persisted as `to` in the local job record so later notification does not need to guess the recipient
- confirm the accepted job was persisted locally with the same `jobId` and `status`

Polling step checks:
- confirm the queried job already exists in `~/.nexu/deploy-skill-jobs.json`
- confirm polling sends the same `Authorization: Bearer <apiKey>` from the local Nexu config
- confirm the server response still references the same `jobId`
- confirm `status` is one of `queued`, `running`, `succeeded`, `failed`, or `cancelled`
- if `status` is `succeeded`, confirm `result.url` exists and ends with `.nexu.space` before generating any success message
- if `status` is `failed` or `cancelled`, relay the server error message and hint instead of inventing one
- if polling times out, only fall back to a temporary `.pages.dev` link when that link already exists in the persisted job record

Output rule:
- If any check fails, stop and return the explicit guard-check error.
- Never claim “deployment started” or “website is ready” until the matching guard checklist has passed.
- Timeout fallback message must be:
`Your page has been deployed to the temporary domain {pagesDevLink}. If you cannot access this domain, you can retry deploy again.`

## Rules

- Never invent a job id, URL, or completion state.
- Never invent the delivery target. Persist the concrete `to` target when it is known.
- Never say the site is ready during submit.
- Always reject malformed server responses instead of guessing missing fields.
- Always use the local persisted job record for recovery and polling.
- If the server returns an explicit error message or hint, relay it instead of rewriting it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env node

import os from "node:os";
import path from "node:path";
import {
loadPageDeployConfig,
queryPageDeployJob,
recoverPendingPageDeployJobs,
savePageDeployConfig,
submitPageDeployJob,
submitPageDeployTemplateJob,
waitForPageDeployJob,
} from "./deploy_skill_core.js";

function nexuHome() {
return process.env.NEXU_HOME?.trim() || path.join(os.homedir(), ".nexu");
}

function parseArgs(argv) {
const [command, ...rest] = argv;
const options = {};

for (let index = 0; index < rest.length; index += 1) {
const token = rest[index];
if (!token?.startsWith("--")) {
continue;
}
const key = token.slice(2);
const value = rest[index + 1];
if (!value || value.startsWith("--")) {
options[key] = true;
continue;
}
options[key] = value;
index += 1;
}

return { command, options };
}

function printJson(payload, stream = process.stdout) {
stream.write(`${JSON.stringify(payload, null, 2)}\n`);
}

function usage() {
printJson(
{
error:
"Usage: deploy_skill.js <setup|check|submit|query|recover|wait-and-deliver> [options]",
},
process.stderr,
);
process.exit(1);
}

async function run() {
const { command, options } = parseArgs(process.argv.slice(2));
const home = nexuHome();

if (!command) {
usage();
}

if (command === "setup") {
if (typeof options["base-url"] !== "string") {
throw new Error("setup requires --base-url");
}
const config = await savePageDeployConfig(home, {
baseUrl: options["base-url"],
});
printJson({ status: "ok", config });
return;
}

if (command === "check") {
const config = await loadPageDeployConfig(home);
if (typeof config.baseUrl !== "string" || config.baseUrl.length === 0) {
throw new Error("deploy-skill baseUrl is not configured.");
}
printJson({ status: "ok", config });
return;
}

if (command === "submit") {
const commonInput = {
nexuHome: home,
botId: String(options["bot-id"] ?? ""),
chatId: String(options["chat-id"] ?? ""),
chatType: String(options["chat-type"] ?? ""),
channel: String(options.channel ?? ""),
to: typeof options.to === "string" ? options.to : undefined,
threadId:
typeof options["thread-id"] === "string"
? options["thread-id"]
: undefined,
accountId:
typeof options["account-id"] === "string"
? options["account-id"]
: undefined,
sessionKey:
typeof options["session-key"] === "string"
? options["session-key"]
: undefined,
userId:
typeof options["user-id"] === "string" ? options["user-id"] : undefined,
};
const result =
typeof options["template-id"] === "string"
? await submitPageDeployTemplateJob({
...commonInput,
templateId: options["template-id"],
contentFile: String(options["content-file"] ?? ""),
})
: await submitPageDeployJob({
...commonInput,
zipPath: String(options.zip ?? ""),
});

printJson(result.spawnPayload);
printJson(
{
jobId: result.job.jobId,
status: result.job.status,
message: "Deployment started. I will notify the user when it finishes.",
},
process.stderr,
);
return;
}

if (command === "query") {
if (typeof options["job-id"] !== "string") {
throw new Error("query requires --job-id");
}
const job = await queryPageDeployJob({
nexuHome: home,
jobId: options["job-id"],
});
printJson(job);
return;
}

if (command === "recover") {
const pendingJobs = await recoverPendingPageDeployJobs({ nexuHome: home });
printJson({
status: "ok",
pendingCount: pendingJobs.length,
jobs: pendingJobs,
});
return;
}

if (command === "wait-and-deliver") {
if (typeof options["job-id"] !== "string") {
throw new Error("wait-and-deliver requires --job-id");
}
const result = await waitForPageDeployJob({
nexuHome: home,
jobId: options["job-id"],
pollIntervalMs: Number(options["poll-interval-ms"] ?? 10000),
maxPolls: Number(options["max-polls"] ?? 30),
});
printJson({
status: result.status,
message: result.message,
jobId: result.job.jobId,
url: result.job.resultUrl,
});
return;
}

usage();
}

run().catch((error) => {
printJson(
{
status: "error",
message: error instanceof Error ? error.message : String(error),
},
process.stderr,
);
process.exit(1);
});
Loading
Loading