diff --git a/cli/commands/deploy.ts b/cli/commands/deploy.ts index af89d8d..69e05d1 100644 --- a/cli/commands/deploy.ts +++ b/cli/commands/deploy.ts @@ -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, @@ -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 '), 'or dfx)'); console.log(' 2. Deploy to IC mainnet with', chalk.bold('--network ic')); } else { console.log(' 1. Interact with your canister at:'); @@ -166,6 +167,39 @@ async function confirmDeployment( return confirmed; } +/** + * Discover the packaged WASM in the current project when no path is given. + * + * Prefers `dist/.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 */ @@ -229,7 +263,7 @@ export function deployCommand(): Command { command .description('Deploy agent WASM to ICP canister') - .argument('', 'path to compiled WASM file') + .argument('[wasm]', 'path to compiled WASM file (defaults to the packaged agent in ./dist)') .option('-n, --network ', 'target network (local or ic)', 'local') .option('-e, --env ', 'named environment from icp.yaml (e.g. dev, staging, production)') .option('-c, --canister-id ', 'existing canister ID (for upgrades)') @@ -238,11 +272,22 @@ export function deployCommand(): Command { .option('--identity ', 'identity name for icp-cli') .option('--cycles ', 'cycles allocation (e.g. 100T)') .option('--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/.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}`)); diff --git a/cli/commands/package.ts b/cli/commands/package.ts index 8847777..4060273 100644 --- a/cli/commands/package.ts +++ b/cli/commands/package.ts @@ -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; @@ -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'; diff --git a/package-lock.json b/package-lock.json index c1bbfb4..b122a00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentvault", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentvault", - "version": "1.0.5", + "version": "1.0.6", "license": "MIT", "dependencies": { "@dfinity/agent": "^3.4.3", diff --git a/package.json b/package.json index 314f4ed..acda0ce 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/deployment/deployer.ts b/src/deployment/deployer.ts index 02215ca..1e9b3f0 100644 --- a/src/deployment/deployer.ts +++ b/src/deployment/deployer.ts @@ -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'; @@ -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) { diff --git a/src/deployment/icp-manifest.ts b/src/deployment/icp-manifest.ts new file mode 100644 index 0000000..0f113e1 --- /dev/null +++ b/src/deployment/icp-manifest.ts @@ -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 `/dist/.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' }; +} diff --git a/src/deployment/index.ts b/src/deployment/index.ts index 5a6f224..a16450e 100644 --- a/src/deployment/index.ts +++ b/src/deployment/index.ts @@ -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, diff --git a/src/icp/icpcli.ts b/src/icp/icpcli.ts index 35f1f95..63ae8e6 100644 --- a/src/icp/icpcli.ts +++ b/src/icp/icpcli.ts @@ -315,10 +315,13 @@ export async function identityDefault(options: IcpCommonOptions = {}): Promise { 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); } /** diff --git a/src/icp/types.ts b/src/icp/types.ts index d4e731d..19ef535 100644 --- a/src/icp/types.ts +++ b/src/icp/types.ts @@ -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 */ diff --git a/src/packaging/compiler.ts b/src/packaging/compiler.ts index e4099fe..83c6b75 100644 --- a/src/packaging/compiler.ts +++ b/src/packaging/compiler.ts @@ -128,10 +128,43 @@ function writeUleb128Bytes(value: number): number[] { } bytes.push(byte); } while (remaining !== 0); - + return bytes; } +/** + * Structurally validate a WASM binary using the runtime's WebAssembly engine. + * (Accessed via globalThis because tsconfig's lib set has no DOM/WebAssembly + * value declarations.) + */ +function wasmEngineValidate(buffer: Buffer): boolean { + const wasm = (globalThis as Record).WebAssembly as + | { validate(bytes: Uint8Array): boolean } + | undefined; + // If the runtime has no WebAssembly engine, fall back to accepting the module + return wasm ? wasm.validate(buffer) : true; +} + +/** + * Write LEB128 encoded signed integer directly to bytes + * (used for i32.const operands, which are signed) + */ +function writeSleb128Bytes(value: number): number[] { + const bytes: number[] = []; + let remaining = value | 0; + + for (;;) { + const byte = remaining & 0x7f; + remaining >>= 7; + const signBit = (byte & 0x40) !== 0; + if ((remaining === 0 && !signBit) || (remaining === -1 && signBit)) { + bytes.push(byte); + return bytes; + } + bytes.push(byte | 0x80); + } +} + /** * Generate a real WASM binary with embedded JavaScript bundle * @@ -142,174 +175,99 @@ function writeUleb128Bytes(value: number): number[] { * - Exported functions for agent lifecycle */ export function generateWasm(config: AgentConfig, javascriptBundle: string): Buffer { - const agentNameBytes = Buffer.from(config.name, 'utf-8'); const jsBytes = Buffer.from(javascriptBundle, 'utf-8'); - // Build sections + // A section is: id byte, payload size (uleb128), payload. + const section = (id: number, payload: Buffer): Buffer => + concatBuffers([[id], writeUleb128Bytes(payload.length), payload]); + + // A vector is: element count (uleb128), elements. + const vec = (items: Buffer[]): Buffer => + concatBuffers([writeUleb128Bytes(items.length), ...items]); + + // A name is: byte length (uleb128), utf-8 bytes. + const name = (value: string): Buffer => { + const bytes = Buffer.from(value, 'utf-8'); + return concatBuffers([writeUleb128Bytes(bytes.length), bytes]); + }; + const sections: Buffer[] = []; - // 1. Custom section with metadata + // 1. Type section: type 0 = () -> i32, type 1 = (i32) -> i32 + sections.push(section(0x01, vec([ + Buffer.from([0x60, 0x00, 0x01, 0x7f]), + Buffer.from([0x60, 0x01, 0x7f, 0x01, 0x7f]), + ]))); + + // 2. Function section: init, step, get_state_ptr, get_state_size + sections.push(section(0x03, vec([ + Buffer.from([0x00]), // init: type 0 + Buffer.from([0x01]), // step: type 1 + Buffer.from([0x00]), // get_state_ptr: type 0 + Buffer.from([0x00]), // get_state_size: type 0 + ]))); + + // 3. Memory section: one memory, sized to hold the embedded JS bundle + const memoryPages = Math.max(1, Math.ceil(jsBytes.length / 65536)); + sections.push(section(0x05, vec([ + concatBuffers([[0x00], writeUleb128Bytes(memoryPages)]), // limits: min only + ]))); + + // 4. Export section + const funcExport = (exportName: string, funcIndex: number): Buffer => + concatBuffers([name(exportName), [0x00], writeUleb128Bytes(funcIndex)]); + sections.push(section(0x07, vec([ + funcExport('init', 0), + funcExport('step', 1), + funcExport('get_state_ptr', 2), + funcExport('get_state_size', 3), + concatBuffers([name('memory'), [0x02], [0x00]]), // export memory 0 + ]))); + + // 5. Code section + // A body is: size (uleb128), local declarations vector, instructions, end (0x0b). + const body = (instructions: number[]): Buffer => { + const content = Buffer.from([0x00, ...instructions, 0x0b]); // no locals + return concatBuffers([writeUleb128Bytes(content.length), content]); + }; + sections.push(section(0x0a, vec([ + body([0x41, 0x00]), // init: i32.const 0 + body([0x20, 0x00]), // step: local.get 0 + body([0x41, 0x00]), // get_state_ptr: i32.const 0 + body([0x41, ...writeSleb128Bytes(jsBytes.length)]), // get_state_size: i32.const + ]))); + + // 6. Data section: active segment placing the JS bundle at offset 0 + sections.push(section(0x0b, vec([ + concatBuffers([ + [0x00], // active segment, memory 0 + [0x41, 0x00, 0x0b], // offset expression: i32.const 0; end + writeUleb128Bytes(jsBytes.length), + jsBytes, + ]), + ]))); + + // 7. Custom section with agent metadata const version = config.version ?? '1.0.0'; - const metadataContent = Buffer.concat([ + const metadataContent = concatBuffers([ Buffer.from('agentvault', 'utf-8'), - Buffer.from([0]), - agentNameBytes, - Buffer.from([0]), + [0], + Buffer.from(config.name, 'utf-8'), + [0], Buffer.from(config.type, 'utf-8'), - Buffer.from([0]), + [0], Buffer.from(version, 'utf-8'), ]); - - const customSectionName = Buffer.from('agent.metadata', 'utf-8'); - const customSection = concatBuffers([ - Buffer.from([0x00]), // section id: custom - concatBuffers([writeUleb128Bytes(customSectionName.length + 1 + metadataContent.length)]), - customSectionName, - Buffer.from([0]), // null terminator - metadataContent, - ]); - sections.push(customSection); - - // 2. Type section - const typeSectionContent = Buffer.concat([ - // Function type 0: () -> i32 - Buffer.from([0x60]), // func type - Buffer.from([0x00]), // 0 params - Buffer.from([0x7f]), // 1 result i32 - // Function type 1: (i32) -> i32 - Buffer.from([0x60]), // func type - Buffer.from([0x01]), // 1 param i32 - Buffer.from([0x7f]), // 1 result i32 - ]); - - const typeSection = concatBuffers([ - Buffer.from([0x01]), // section id: type - concatBuffers([writeUleb128Bytes(typeSectionContent.length)]), - concatBuffers([writeUleb128Bytes(2)]), // 2 types - typeSectionContent, - ]); - sections.push(typeSection); - - // 3. Function section - const funcSectionContent = Buffer.concat([ - // Function 0: uses type 0 - Buffer.from([0x00]), - // Function 1: uses type 1 - Buffer.from([0x01]), - ]); - - const funcSection = concatBuffers([ - Buffer.from([0x03]), // section id: function - concatBuffers([writeUleb128Bytes(funcSectionContent.length)]), - concatBuffers([writeUleb128Bytes(2)]), // 2 functions - funcSectionContent, - ]); - sections.push(funcSection); - - // 4. Memory section - const memorySection = Buffer.concat([ - Buffer.from([0x05]), // section id: memory - Buffer.from([0x01, 0x01]), // 1 memory, min 1 page (64KB) - ]); - sections.push(memorySection); - - // 5. Export section - const exportSectionNames = ['init', 'step', 'get_state_ptr', 'get_state_size']; - const exportSectionContent = Buffer.concat([ - // Export 0: init - concatBuffers([writeUleb128Bytes((exportSectionNames[0] as string).length)]), - Buffer.from(exportSectionNames[0] as string, 'utf-8'), - Buffer.from([0x00]), // export kind: function - Buffer.from([0x00]), // func index - // Export 1: step - concatBuffers([writeUleb128Bytes((exportSectionNames[1] as string).length)]), - Buffer.from(exportSectionNames[1] as string, 'utf-8'), - Buffer.from([0x00]), - Buffer.from([0x01]), - // Export 2: get_state_ptr - concatBuffers([writeUleb128Bytes((exportSectionNames[2] as string).length)]), - Buffer.from(exportSectionNames[2] as string, 'utf-8'), - Buffer.from([0x00]), - Buffer.from([0x02]), - // Export 3: get_state_size - concatBuffers([writeUleb128Bytes((exportSectionNames[3] as string).length)]), - Buffer.from(exportSectionNames[3] as string, 'utf-8'), - Buffer.from([0x00]), - Buffer.from([0x03]), - ]); - - const exportSection = concatBuffers([ - Buffer.from([0x07]), // section id: export - concatBuffers([writeUleb128Bytes(exportSectionContent.length)]), - concatBuffers([writeUleb128Bytes(4)]), // 4 exports - exportSectionContent, - ]); - sections.push(exportSection); - - // 6. Code section with function bodies - // Function 0: init - returns success (0) - const funcBody0 = Buffer.concat([ - Buffer.from([0x0b, 0x01]), // func body size - Buffer.from([0x00, 0x41, 0x00, 0x0b]), // i32.const 0; return - ]); - - // Function 1: step - returns input - const funcBody1 = Buffer.concat([ - Buffer.from([0x0b, 0x02]), // func body size - Buffer.from([0x20, 0x00, 0x0b]), // local.get 0; return - ]); - - // Function 2: get_state_ptr - returns 0 (memory offset) - const funcBody2 = Buffer.concat([ - Buffer.from([0x0b, 0x01]), // func body size - Buffer.from([0x41, 0x00, 0x0b]), // i32.const 0; return - ]); - - // Function 3: get_state_size - returns js bundle size - const funcBody3 = Buffer.concat([ - Buffer.from([0x0b, 0x06]), // func body size - Buffer.from([0x41]), // i32.const - concatBuffers([writeUleb128Bytes(jsBytes.length)]), - Buffer.from([0x0b]), // return - ]); - - const codeSectionContent = Buffer.concat([ - concatBuffers([writeUleb128Bytes(funcBody0.length)]), - funcBody0, - concatBuffers([writeUleb128Bytes(funcBody1.length)]), - funcBody1, - concatBuffers([writeUleb128Bytes(funcBody2.length)]), - funcBody2, - concatBuffers([writeUleb128Bytes(funcBody3.length)]), - funcBody3, - ]); - - const codeSection = concatBuffers([ - Buffer.from([0x0a]), // section id: code - concatBuffers([writeUleb128Bytes(codeSectionContent.length)]), - concatBuffers([writeUleb128Bytes(4)]), // 4 function bodies - codeSectionContent, - ]); - sections.push(codeSection); - - // 7. Data section with embedded JavaScript - const dataSectionContent = Buffer.concat([ - Buffer.from([0x00, 0x41, 0x00, 0x0b]), // memory 0, i32.const 0 - concatBuffers([writeUleb128Bytes(jsBytes.length)]), - jsBytes, - ]); - - const dataSection = concatBuffers([ - Buffer.from([0x0b]), // section id: data - concatBuffers([writeUleb128Bytes(dataSectionContent.length)]), - Buffer.from([0x01]), // 1 data segment - dataSectionContent, - ]); - sections.push(dataSection); - - // Combine all sections into final WASM + sections.push(section(0x00, concatBuffers([name('agent.metadata'), metadataContent]))); + const wasmBuffer = Buffer.concat([WASM_MAGIC, WASM_VERSION, ...sections]); - + + // Guard against regressions in the hand-rolled encoder: never emit a + // module the WebAssembly engine itself rejects. + if (!wasmEngineValidate(wasmBuffer)) { + throw new Error('Generated WASM module failed WebAssembly validation'); + } + return wasmBuffer; } @@ -640,7 +598,8 @@ export function validateWasmFile(filePath: string): boolean { return false; } - return true; + // Full structural validation by the WebAssembly engine + return wasmEngineValidate(buffer); } catch { return false; } diff --git a/tests/cli/commands/deploy.test.ts b/tests/cli/commands/deploy.test.ts index 28b2f1f..a6b6be3 100644 --- a/tests/cli/commands/deploy.test.ts +++ b/tests/cli/commands/deploy.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { deployCommand, displayPreview, displayResult, executeDeploy } from '../../../cli/commands/deploy.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { deployCommand, discoverWasmPath, displayPreview, displayResult, executeDeploy } from '../../../cli/commands/deploy.js'; import type { DeployResult } from '../../../src/deployment/types.js'; // Mock the deployment module @@ -26,6 +29,7 @@ vi.mock('chalk', () => ({ yellow: (str: string) => str, cyan: (str: string) => str, red: (str: string) => str, + gray: (str: string) => str, }, })); @@ -78,12 +82,52 @@ describe('deploy command', () => { expect(dryRunOption).toBeDefined(); }); - it('should require wasm argument', () => { + it('should accept an optional wasm argument', () => { const command = deployCommand(); const args = command.registeredArguments; expect(args.length).toBe(1); expect(args[0]?.name()).toBe('wasm'); - expect(args[0]?.required).toBe(true); + expect(args[0]?.required).toBe(false); + }); + }); + + describe('discoverWasmPath', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-discover-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should find the wasm named in agent.json', () => { + fs.writeFileSync(path.join(tmpDir, 'agent.json'), JSON.stringify({ name: 'my-agent' })); + fs.mkdirSync(path.join(tmpDir, 'dist')); + fs.writeFileSync(path.join(tmpDir, 'dist', 'my-agent.wasm'), 'wasm'); + fs.writeFileSync(path.join(tmpDir, 'dist', 'other.wasm'), 'wasm'); + + expect(discoverWasmPath(tmpDir)).toBe(path.join(tmpDir, 'dist', 'my-agent.wasm')); + }); + + it('should find a single dist wasm without agent.json', () => { + fs.mkdirSync(path.join(tmpDir, 'dist')); + fs.writeFileSync(path.join(tmpDir, 'dist', 'solo.wasm'), 'wasm'); + + expect(discoverWasmPath(tmpDir)).toBe(path.join(tmpDir, 'dist', 'solo.wasm')); + }); + + it('should return null when dist has multiple wasms and no agent.json', () => { + fs.mkdirSync(path.join(tmpDir, 'dist')); + fs.writeFileSync(path.join(tmpDir, 'dist', 'a.wasm'), 'wasm'); + fs.writeFileSync(path.join(tmpDir, 'dist', 'b.wasm'), 'wasm'); + + expect(discoverWasmPath(tmpDir)).toBeNull(); + }); + + it('should return null when nothing is packaged', () => { + expect(discoverWasmPath(tmpDir)).toBeNull(); }); }); diff --git a/tests/deployment/deployer.test.ts b/tests/deployment/deployer.test.ts index 3917829..e6cdea9 100644 --- a/tests/deployment/deployer.test.ts +++ b/tests/deployment/deployer.test.ts @@ -33,6 +33,14 @@ vi.mock('../../src/icp/tool-detector.js', () => ({ // Mock icpcli vi.mock('../../src/icp/icpcli.js', () => ({ deploy: vi.fn(), + networkStatus: vi.fn(), + networkStart: vi.fn(), +})); + +// Mock icp-manifest generation (writes to the real filesystem) +vi.mock('../../src/deployment/icp-manifest.js', () => ({ + ensureIcpManifest: vi.fn(() => ({ path: '/project/icp.yaml', action: 'created' })), + resolveProjectRootForWasm: vi.fn(() => '/project'), })); // Mock environment @@ -284,6 +292,108 @@ describe('deployer', () => { }); }); + describe('deployAgent via icp-cli', () => { + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true, size: 1024 } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]) + ); + vi.mocked(detectToolchain).mockResolvedValue({ + icWasm: { name: 'ic-wasm', available: false, path: '', version: '' }, + icp: { name: 'icp', available: true, path: '/usr/local/bin/icp', version: '0.1.0' }, + dfx: { name: 'dfx', available: false, path: '', version: '' }, + preferredDeployTool: 'icp', + canOptimize: false, + }); + vi.mocked(icpcli.networkStatus).mockResolvedValue({ + success: true, + stdout: 'Port: 8000', + stderr: '', + exitCode: 0, + }); + vi.mocked(icpcli.deploy).mockResolvedValue({ + success: true, + stdout: 'Deployed canister: rrkah-fqaaa-aaaaa-aaaaa-aaaaa-cai', + stderr: '', + exitCode: 0, + }); + }); + + it('should generate an icp.yaml manifest and pin the project root', async () => { + const { ensureIcpManifest } = await import('../../src/deployment/icp-manifest.js'); + + const result = await deployAgent({ + wasmPath: '/project/dist/my-agent.wasm', + network: 'local', + }); + + expect(ensureIcpManifest).toHaveBeenCalledWith('/project', 'my-agent', '/project/dist/my-agent.wasm'); + expect(icpcli.deploy).toHaveBeenCalledWith( + expect.objectContaining({ projectRoot: '/project', environment: 'local' }) + ); + expect(result.deployTool).toBe('icp'); + }); + + it('should start the local network when it is not running', async () => { + vi.mocked(icpcli.networkStatus).mockResolvedValue({ + success: false, + stdout: '', + stderr: 'the local network for this project is not running', + exitCode: 1, + }); + vi.mocked(icpcli.networkStart).mockResolvedValue({ + success: true, + stdout: 'Network started on port 8000', + stderr: '', + exitCode: 0, + }); + + const result = await deployAgent({ + wasmPath: '/project/dist/my-agent.wasm', + network: 'local', + }); + + expect(icpcli.networkStart).toHaveBeenCalledWith( + expect.objectContaining({ projectRoot: '/project', background: true }) + ); + expect(result.warnings.some((w) => w.includes('started it in the background'))).toBe(true); + }); + + it('should throw when the local network cannot be started', async () => { + vi.mocked(icpcli.networkStatus).mockResolvedValue({ + success: false, + stdout: '', + stderr: 'not running', + exitCode: 1, + }); + vi.mocked(icpcli.networkStart).mockResolvedValue({ + success: false, + stdout: '', + stderr: 'launcher missing', + exitCode: 1, + }); + + await expect( + deployAgent({ + wasmPath: '/project/dist/my-agent.wasm', + network: 'local', + }) + ).rejects.toThrow('Failed to start the local ICP network'); + }); + + it('should not check the network for ic deploys', async () => { + await deployAgent({ + wasmPath: '/project/dist/my-agent.wasm', + network: 'ic', + skipConfirmation: true, + }); + + expect(icpcli.networkStatus).not.toHaveBeenCalled(); + expect(icpcli.networkStart).not.toHaveBeenCalled(); + }); + }); + describe('getCanisterStatus', () => { it('should return canister status when exists', async () => { const status = await getCanisterStatus('rrkah-fqaaa-aaaaa-aaaaa-aaaaa-cai', 'local'); diff --git a/tests/deployment/icp-manifest.test.ts b/tests/deployment/icp-manifest.test.ts new file mode 100644 index 0000000..327d435 --- /dev/null +++ b/tests/deployment/icp-manifest.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + ensureIcpManifest, + resolveProjectRootForWasm, + icpManifestPath, + GENERATED_MARKER, +} from '../../src/deployment/icp-manifest.js'; + +describe('icp-manifest', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'icp-manifest-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('resolveProjectRootForWasm', () => { + it('should use the parent of a dist directory as project root', () => { + const wasmPath = path.join(tmpDir, 'my-agent', 'dist', 'my-agent.wasm'); + expect(resolveProjectRootForWasm(wasmPath)).toBe(path.join(tmpDir, 'my-agent')); + }); + + it('should use the containing directory when not in dist', () => { + const wasmPath = path.join(tmpDir, 'build', 'my-agent.wasm'); + expect(resolveProjectRootForWasm(wasmPath)).toBe(path.join(tmpDir, 'build')); + }); + }); + + describe('icpManifestPath', () => { + it('should return icp.yaml in the project root', () => { + expect(icpManifestPath(tmpDir)).toBe(path.join(tmpDir, 'icp.yaml')); + }); + }); + + describe('ensureIcpManifest', () => { + it('should create a manifest when none exists', () => { + const wasmPath = path.join(tmpDir, 'dist', 'my-agent.wasm'); + const result = ensureIcpManifest(tmpDir, 'my-agent', wasmPath); + + expect(result.action).toBe('created'); + expect(fs.existsSync(result.path)).toBe(true); + + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content.startsWith(GENERATED_MARKER)).toBe(true); + expect(content).toContain('name: my-agent'); + expect(content).toContain('cp "dist/my-agent.wasm" "$ICP_WASM_OUTPUT_PATH"'); + }); + + it('should use forward slashes in the wasm path', () => { + const wasmPath = path.join(tmpDir, 'dist', 'nested', 'my-agent.wasm'); + const result = ensureIcpManifest(tmpDir, 'my-agent', wasmPath); + + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content).toContain('dist/nested/my-agent.wasm'); + expect(content).not.toContain('\\'); + }); + + it('should update a previously generated manifest', () => { + const wasmPath = path.join(tmpDir, 'dist', 'my-agent.wasm'); + ensureIcpManifest(tmpDir, 'my-agent', wasmPath); + + const renamed = path.join(tmpDir, 'dist', 'renamed-agent.wasm'); + const result = ensureIcpManifest(tmpDir, 'renamed-agent', renamed); + + expect(result.action).toBe('updated'); + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content).toContain('name: renamed-agent'); + expect(content).not.toContain('name: my-agent'); + }); + + it('should not overwrite a user-managed manifest', () => { + const manifestPath = icpManifestPath(tmpDir); + const userContent = 'canisters:\n - name: custom\n'; + fs.writeFileSync(manifestPath, userContent, 'utf-8'); + + const wasmPath = path.join(tmpDir, 'dist', 'my-agent.wasm'); + const result = ensureIcpManifest(tmpDir, 'my-agent', wasmPath); + + expect(result.action).toBe('kept'); + expect(fs.readFileSync(manifestPath, 'utf-8')).toBe(userContent); + }); + }); +}); diff --git a/tests/packaging/compiler.test.ts b/tests/packaging/compiler.test.ts index c153eb6..7538307 100644 --- a/tests/packaging/compiler.test.ts +++ b/tests/packaging/compiler.test.ts @@ -111,6 +111,54 @@ describe('compiler', () => { expect(wasmString).toContain('console.log'); }); + // tsconfig's lib set has no WebAssembly value declarations, so access + // the engine through globalThis with a local type. + const wasmEngine = (globalThis as Record).WebAssembly as { + validate(bytes: Uint8Array): boolean; + instantiate(bytes: Uint8Array): Promise<{ instance: { exports: Record } }>; + }; + + it('should pass WebAssembly engine validation', () => { + const jsBundle = 'console.log("test");'; + const wasm = generateWasm(mockConfig, jsBundle); + + expect(wasmEngine.validate(wasm)).toBe(true); + }); + + it('should instantiate with working lifecycle exports', async () => { + const jsBundle = 'console.log("bundle payload");'; + const wasm = generateWasm(mockConfig, jsBundle); + + const { instance } = await wasmEngine.instantiate(wasm); + const exports = instance.exports as { + init: () => number; + step: (input: number) => number; + get_state_ptr: () => number; + get_state_size: () => number; + memory: { buffer: ArrayBuffer }; + }; + + expect(exports.init()).toBe(0); + expect(exports.step(42)).toBe(42); + expect(exports.get_state_ptr()).toBe(0); + expect(exports.get_state_size()).toBe(Buffer.byteLength(jsBundle, 'utf-8')); + + // The JS bundle is placed at offset 0 of the exported memory + const stored = Buffer.from( + exports.memory.buffer, + exports.get_state_ptr(), + exports.get_state_size() + ).toString('utf-8'); + expect(stored).toBe(jsBundle); + }); + + it('should validate large bundles spanning multiple memory pages', () => { + const jsBundle = 'x'.repeat(200_000); // > 3 pages of 64KB + const wasm = generateWasm(mockConfig, jsBundle); + + expect(wasmEngine.validate(wasm)).toBe(true); + }); + it('should generate different sizes for different agent names', () => { const shortNameConfig = { ...mockConfig, name: 'a' }; const longNameConfig = { ...mockConfig, name: 'very-long-agent-name' };