Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
53 changes: 49 additions & 4 deletions cli/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import inquirer from 'inquirer';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
deployAgent,
Expand Down Expand Up @@ -135,7 +136,7 @@ export function displayResult(result: DeployResult): void {
console.log();
console.log(chalk.cyan('Next steps:'));
if (result.canister.network === 'local') {
console.log(' 1. Test your agent locally with dfx');
console.log(' 1. Test your agent locally (e.g.', chalk.bold('icp canister status <name>'), 'or dfx)');
console.log(' 2. Deploy to IC mainnet with', chalk.bold('--network ic'));
} else {
console.log(' 1. Interact with your canister at:');
Expand Down Expand Up @@ -166,6 +167,39 @@ async function confirmDeployment(
return confirmed;
}

/**
* Discover the packaged WASM in the current project when no path is given.
*
* Prefers `dist/<agent-name>.wasm` (name from agent.json); otherwise a
* single `dist/*.wasm` is used. Returns null when nothing unambiguous
* is found.
*/
export function discoverWasmPath(cwd: string = process.cwd()): string | null {
const distDir = path.join(cwd, 'dist');

// agent.json names the agent — its wasm is the packaged output
try {
const agentConfig = JSON.parse(fs.readFileSync(path.join(cwd, 'agent.json'), 'utf-8')) as { name?: string };
if (agentConfig.name) {
const candidate = path.join(distDir, `${agentConfig.name}.wasm`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// No agent.json or unparseable — fall through to directory scan
}

if (fs.existsSync(distDir)) {
const wasmFiles = fs.readdirSync(distDir).filter((f) => f.endsWith('.wasm'));
if (wasmFiles.length === 1) {
return path.join(distDir, wasmFiles[0]!);
}
}

return null;
}

/**
* Execute the deploy command
*/
Expand Down Expand Up @@ -229,7 +263,7 @@ export function deployCommand(): Command {

command
.description('Deploy agent WASM to ICP canister')
.argument('<wasm>', 'path to compiled WASM file')
.argument('[wasm]', 'path to compiled WASM file (defaults to the packaged agent in ./dist)')
.option('-n, --network <network>', 'target network (local or ic)', 'local')
.option('-e, --env <environment>', 'named environment from icp.yaml (e.g. dev, staging, production)')
.option('-c, --canister-id <id>', 'existing canister ID (for upgrades)')
Expand All @@ -238,11 +272,22 @@ export function deployCommand(): Command {
.option('--identity <name>', 'identity name for icp-cli')
.option('--cycles <amount>', 'cycles allocation (e.g. 100T)')
.option('--mode <mode>', 'deploy mode: auto, install, reinstall, upgrade')
.action(async (wasm: string, options: DeployCommandOptions) => {
.action(async (wasm: string | undefined, options: DeployCommandOptions) => {
console.log(chalk.bold('\n🚀 AgentVault Deploy\n'));

const wasmPath = wasm ?? discoverWasmPath();
if (!wasmPath) {
console.error(chalk.red('No packaged WASM found in ./dist.'));
console.error(`Run ${chalk.bold('agentvault package ./')} first, or pass the path explicitly:`);
console.error(` ${chalk.bold('agentvault deploy dist/<name>.wasm')}`);
process.exit(1);
}
if (!wasm) {
console.log(chalk.gray(`Using packaged WASM: ${path.relative(process.cwd(), wasmPath)}\n`));
}

try {
await executeDeploy(wasm, options);
await executeDeploy(wasmPath, options);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(chalk.red(`\nError: ${message}`));
Expand Down
18 changes: 18 additions & 0 deletions cli/commands/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { packageAgent, getPackageSummary } from '../../src/packaging/index.js';
import type { PackageOptions, PackageResult } from '../../src/packaging/index.js';
import { ensureIcpManifest } from '../../src/deployment/index.js';

export interface PackageCommandOptions {
output?: string;
Expand Down Expand Up @@ -180,6 +182,22 @@ export async function executePackage(
spinner.succeed(`Agent '${result.config.name}' packaged successfully!`);
displayResult(result);

// Generate the icp.yaml project manifest so `icp deploy` (icp-cli)
// can deploy the packaged WASM directly.
try {
const resolvedSource = path.resolve(sourcePath);
const projectRoot = fs.statSync(resolvedSource).isDirectory()
? resolvedSource
: path.dirname(resolvedSource);
const manifest = ensureIcpManifest(projectRoot, result.config.name, result.wasmPath);
if (manifest.action !== 'kept') {
console.log(` ICP Manifest: ${manifest.path}`);
}
} catch (manifestError) {
const message = manifestError instanceof Error ? manifestError.message : 'Unknown error';
console.log(chalk.yellow(` ⚠ Could not write icp.yaml manifest: ${message}`));
}

return result;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentvault",
"version": "1.0.5",
"version": "1.0.6",
"description": "Production infrastructure for deploying and running AI agents on Internet Computer canisters",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
Expand Down
29 changes: 28 additions & 1 deletion src/deployment/deployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
DeploymentStatus,
} from './types.js';
import { createICPClient } from './icpClient.js';
import { ensureIcpManifest, resolveProjectRootForWasm } from './icp-manifest.js';
import { detectToolchain } from '../icp/tool-detector.js';
import * as icpcli from '../icp/icpcli.js';
import { getEnvironment } from '../icp/environment.js';
Expand Down Expand Up @@ -329,11 +330,37 @@ async function deployWithIcpCli(
// Determine deploy mode
const mode = options.mode ?? (options.canisterId ? 'upgrade' : 'auto');

// Pin the project root to the agent project so icp-cli does not walk up
// the directory tree and pick up an unrelated icp.yaml.
const projectRoot = options.projectRoot ?? resolveProjectRootForWasm(options.wasmPath);

// icp-cli deploys from an icp.yaml manifest, not a bare WASM path —
// generate one pointing at the packaged WASM if the project lacks it.
const manifest = ensureIcpManifest(projectRoot, extractAgentName(options.wasmPath), options.wasmPath);
if (manifest.action === 'created') {
warnings.push(`Generated ${manifest.path} for icp-cli deployment`);
}

// Local deploys need the managed local network running.
if (envName === 'local') {
const status = await icpcli.networkStatus({ projectRoot });
if (!status.success) {
warnings.push('Local ICP network was not running — started it in the background (stop it with `icp network stop`).');
const started = await icpcli.networkStart({ projectRoot, background: true });
if (!started.success) {
throw new Error(
`Failed to start the local ICP network: ${started.stderr || started.stdout}\n` +
'Start it manually with `icp network start -d` and retry.'
);
}
}
}

const result = await icpcli.deploy({
environment: envName,
identity,
mode,
projectRoot: options.projectRoot,
projectRoot,
});

if (!result.success) {
Expand Down
95 changes: 95 additions & 0 deletions src/deployment/icp-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* icp-cli Project Manifest Generation
*
* The external `icp` CLI (icp-cli) requires an `icp.yaml` project manifest.
* Its schema (canisters/environments as sequences) is different from
* AgentVault's internal environment config, so generated agent projects
* need their own manifest for `icp deploy` to work.
*
* The generated manifest uses a plain script build step that copies the
* packaged WASM into place. This avoids the remote recipe registry
* (e.g. @dfinity/prebuilt), whose templates are not compatible with all
* icp-cli binary versions.
*/

import * as fs from 'node:fs';
import * as path from 'node:path';

/**
* Header marker identifying manifests generated by AgentVault.
* Manifests without this marker are never overwritten.
*/
export const GENERATED_MARKER = '# Generated by agentvault';

/**
* Resolve the project root for a packaged WASM file.
* Packaged agents place WASM in `<project>/dist/<name>.wasm`, so the
* parent of a `dist` directory is treated as the project root.
*/
export function resolveProjectRootForWasm(wasmPath: string): string {
const absWasm = path.resolve(wasmPath);
const dir = path.dirname(absWasm);
return path.basename(dir) === 'dist' ? path.dirname(dir) : dir;
}

/**
* Path of the icp.yaml manifest for a project root.
*/
export function icpManifestPath(projectRoot: string): string {
return path.join(projectRoot, 'icp.yaml');
}

export interface EnsureManifestResult {
/** Absolute path to the manifest */
path: string;
/** What happened: created new, refreshed a generated one, or kept a user-managed one */
action: 'created' | 'updated' | 'kept';
}

/**
* Ensure an icp-cli-compatible manifest exists for the project.
*
* - No icp.yaml: writes one referencing the packaged WASM.
* - Existing icp.yaml with the AgentVault marker: rewritten (WASM path or
* agent name may have changed since the last package).
* - Existing icp.yaml without the marker: left untouched.
*
* @param projectRoot - Agent project root directory
* @param canisterName - Canister name (usually the agent name)
* @param wasmPath - Path to the packaged WASM file
*/
export function ensureIcpManifest(
projectRoot: string,
canisterName: string,
wasmPath: string
): EnsureManifestResult {
const manifestPath = icpManifestPath(projectRoot);
const exists = fs.existsSync(manifestPath);

if (exists) {
const current = fs.readFileSync(manifestPath, 'utf-8');
if (!current.startsWith(GENERATED_MARKER)) {
return { path: manifestPath, action: 'kept' };
}
}

const relWasm = path
.relative(projectRoot, path.resolve(wasmPath))
.split(path.sep)
.join('/');

const content = [
`${GENERATED_MARKER} — regenerated on each \`agentvault package\`; remove this header line to manage it yourself.`,
'canisters:',
` - name: ${canisterName}`,
' build:',
' steps:',
' - type: script',
' commands:',
` - cp "${relWasm}" "$ICP_WASM_OUTPUT_PATH"`,
'',
].join('\n');

fs.writeFileSync(manifestPath, content, 'utf-8');
return { path: manifestPath, action: exists ? 'updated' : 'created' };
}
9 changes: 9 additions & 0 deletions src/deployment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export {
getCanisterStatus,
} from './deployer.js';

// icp-cli manifest generation
export {
ensureIcpManifest,
resolveProjectRootForWasm,
icpManifestPath,
GENERATED_MARKER,
type EnsureManifestResult,
} from './icp-manifest.js';

// Promotion
export {
loadDeploymentHistory,
Expand Down
5 changes: 4 additions & 1 deletion src/icp/icpcli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,10 +315,13 @@ export async function identityDefault(options: IcpCommonOptions = {}): Promise<I
*/
export async function networkStart(options: IcpNetworkStartOptions = {}): Promise<IcpCliResult> {
const args = ['network', 'start', ...buildCommonArgs(options)];
if (options.background) {
args.push('-d');
}
if (options.network) {
args.push(options.network);
}
return runIcp(args, 60_000, options.projectRoot);
return runIcp(args, 120_000, options.projectRoot);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/icp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,8 @@ export interface IcpIdentityImportOptions extends IcpCommonOptions {
export interface IcpNetworkStartOptions extends IcpCommonOptions {
/** Network name */
network?: string;
/** Run the network in the background (-d) */
background?: boolean;
}

/** Options for icp network stop */
Expand Down
Loading
Loading