diff --git a/packages/config/src/cli/run.context.ts b/packages/config/src/cli/run.context.ts new file mode 100644 index 000000000..9b92c1094 --- /dev/null +++ b/packages/config/src/cli/run.context.ts @@ -0,0 +1,70 @@ +import type {Identity} from '@dfinity/agent'; +import type {Principal} from '@dfinity/principal'; +import * as z from 'zod/v4'; +import {StrictIdentitySchema} from '../utils/identity.utils'; +import {StrictPrincipalSchema} from '../utils/principal.utils'; +import {createFunctionSchema} from '../utils/zod.utils'; + +/** + * @see OnRunContext + */ +export const OnRunContextSchema = z.strictObject({ + satelliteId: StrictPrincipalSchema, + identity: StrictIdentitySchema, + container: z.string().optional() +}); + +/** + * The context for running a task. + */ +export interface OnRunContext { + /** + * The Satellite ID as defined in the `juno.config` file. + * + * A {@link Principal} instance. + */ + satelliteId: Principal; + + /** + * The {@link Identity} used by the CLI for this execution, + * resolved according to the selected mode and profile. + */ + identity: Identity; + + /** + * A custom container URL. Useful when your local emulator runs on a non-default URL or port. + * @type {string} + * @optional + */ + container?: string; +} + +/** + * @see RunFunction + */ +const RunFunctionSchema = z.function({ + input: z.tuple([OnRunContextSchema]), + output: z.promise(z.void()).or(z.void()) +}); +/** + * The function executed by a task. + */ +export type RunFunction = (context: OnRunContext) => void | Promise; + +/** + * @see OnRun + */ +export const OnRunSchema = z.strictObject({ + run: createFunctionSchema(RunFunctionSchema) +}); + +/** + * A runner (job) executed with `juno run`. + */ +export interface OnRun { + /** + * The function that will be executed and called with parameters + * inherited from your configuration and CLI. + */ + run: RunFunction; +} diff --git a/packages/config/src/cli/run.env.ts b/packages/config/src/cli/run.env.ts new file mode 100644 index 000000000..812e89784 --- /dev/null +++ b/packages/config/src/cli/run.env.ts @@ -0,0 +1,19 @@ +import * as z from 'zod/v4'; +import {type JunoConfigEnv, JunoConfigEnvSchema} from '../types/juno.env'; + +/** + * @see OnRunEnv + */ +export const OnRunEnvSchema = JunoConfigEnvSchema.extend({ + profile: z.string().optional() +}); + +/** + * The environment available when executing `juno run`. + */ +export type OnRunEnv = JunoConfigEnv & { + /** + * Optional profile (e.g. `personal`, `team`) used for execution. + */ + profile?: string; +}; diff --git a/packages/config/src/cli/run.ts b/packages/config/src/cli/run.ts new file mode 100644 index 000000000..1d37c7072 --- /dev/null +++ b/packages/config/src/cli/run.ts @@ -0,0 +1,20 @@ +import * as z from 'zod/v4'; +import {createFunctionSchema} from '../utils/zod.utils'; +import {type OnRun, OnRunContextSchema, OnRunSchema} from './run.context'; +import type {OnRunEnv} from './run.env'; + +export const RunFnSchema = z.function({ + input: z.tuple([OnRunContextSchema]), + output: OnRunSchema +}); +export type RunFn = (context: OnRunEnv) => OnRun; + +export const RunFnOrObjectSchema = z.union([OnRunSchema, createFunctionSchema(RunFnSchema)]); +export type RunFnOrObject = OnRun | RunFn; + +export function defineRun(run: OnRun): OnRun; +export function defineRun(run: RunFn): RunFn; +export function defineRun(run: RunFnOrObject): RunFnOrObject; +export function defineRun(run: RunFnOrObject): RunFnOrObject { + return run; +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 8d6ac275e..8022ccd3f 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,3 +1,6 @@ +export * from './cli/run'; +export * from './cli/run.context'; +export * from './cli/run.env'; export * from './console/config'; export * from './console/console.config'; export * from './pkg/juno.package'; diff --git a/packages/config/src/tests/cli/run.context.spec.ts b/packages/config/src/tests/cli/run.context.spec.ts new file mode 100644 index 000000000..04aa70f65 --- /dev/null +++ b/packages/config/src/tests/cli/run.context.spec.ts @@ -0,0 +1,138 @@ +import {Principal} from '@dfinity/principal'; +import {OnRun, OnRunContextSchema, OnRunSchema} from '../../cli/run.context'; +import {mockIdentity} from '../mocks/identity.mock'; +import {mockUserIdPrincipal} from '../mocks/principal.mock'; + +describe('run.context', () => { + describe('OnRunContextSchema', () => { + it('accepts a valid context with required fields only', () => { + const result = OnRunContextSchema.safeParse({ + satelliteId: mockUserIdPrincipal, + identity: mockIdentity + }); + expect(result.success).toBe(true); + }); + + it('should not accept a container as boolean', () => { + const result = OnRunContextSchema.safeParse({ + satelliteId: mockUserIdPrincipal, + identity: mockIdentity, + container: true + }); + expect(result.success).toBe(false); + }); + + it('accepts with container as string (URL)', () => { + const result = OnRunContextSchema.safeParse({ + satelliteId: mockUserIdPrincipal, + identity: mockIdentity, + container: 'http://localhost:4943' + }); + expect(result.success).toBe(true); + }); + + it('rejects if satelliteId is missing', () => { + const result = OnRunContextSchema.safeParse({ + identity: mockIdentity + }); + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['satelliteId']); + } + }); + + it('rejects if identity is missing', () => { + const result = OnRunContextSchema.safeParse({ + satelliteId: mockUserIdPrincipal + }); + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['identity']); + } + }); + + it('rejects if container is not boolean or string', () => { + const result = OnRunContextSchema.safeParse({ + satelliteId: mockUserIdPrincipal, + identity: mockIdentity, + container: 123 + }); + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['container']); + } + }); + + it('is strict: rejects unknown keys', () => { + const result = OnRunContextSchema.safeParse({ + satelliteId: mockUserIdPrincipal, + identity: mockIdentity, + extra: 'nope' + }); + expect(result.success).toBe(false); + }); + }); + + describe('OnRunSchema', () => { + it('accepts a sync run function', () => { + const onRun: OnRun = { + run: (ctx) => { + const parsed = OnRunContextSchema.parse(ctx); + expect(parsed.satelliteId.toText()).toBe('2vxsx-fae'); + } + }; + const res = OnRunSchema.safeParse(onRun); + expect(res.success).toBe(true); + }); + + it('accepts an async run function', async () => { + const onRun: OnRun = { + run: async (ctx) => { + const parsed = OnRunContextSchema.parse(ctx); + expect(parsed.identity).toBeTruthy(); + } + }; + const res = OnRunSchema.safeParse(onRun); + expect(res.success).toBe(true); + }); + + it('rejects when run is not a function', () => { + expect(() => + OnRunSchema.parse({ + run: 'not-a-function' + }) + ).toThrow(); + }); + + it('rejects when run is missing', () => { + expect(() => OnRunSchema.parse({})).toThrow(); + }); + + it('executes a validated run function at runtime', async () => { + const onRun: OnRun = { + run: async (ctx: unknown) => { + const parsed = OnRunContextSchema.parse(ctx); + expect(parsed.satelliteId).toBeInstanceOf(Principal); + } + }; + + const parsed = OnRunSchema.parse(onRun); + await parsed.run({ + satelliteId: Principal.fromText('jx5yt-yyaaa-aaaal-abzbq-cai'), + identity: mockIdentity + }); + }); + + it('is strict: rejects unknown keys at top-level OnRun object', () => { + const onRun: any = { + run: () => {}, + extra: 'nope' + }; + const res = OnRunSchema.safeParse(onRun); + expect(res.success).toBe(false); + }); + }); +}); diff --git a/packages/config/src/tests/cli/run.env.spec.ts b/packages/config/src/tests/cli/run.env.spec.ts new file mode 100644 index 000000000..3a5b67ba8 --- /dev/null +++ b/packages/config/src/tests/cli/run.env.spec.ts @@ -0,0 +1,38 @@ +import {OnRunEnvSchema} from '../../cli/run.env'; + +describe('run.env', () => { + describe('OnRunEnvSchema', () => { + it('accepts valid env with mode only', () => { + const result = OnRunEnvSchema.safeParse({mode: 'production'}); + expect(result.success).toBe(true); + }); + + it('accepts valid env with mode and profile', () => { + const result = OnRunEnvSchema.safeParse({mode: 'staging', profile: 'team'}); + expect(result.success).toBe(true); + }); + + it('rejects if profile is not a string', () => { + const result = OnRunEnvSchema.safeParse({mode: 'production', profile: 123}); + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['profile']); + } + }); + + it('rejects if mode is missing', () => { + const result = OnRunEnvSchema.safeParse({profile: 'personal'}); + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['mode']); + } + }); + + it('rejects null/undefined mode', () => { + expect(OnRunEnvSchema.safeParse({mode: null}).success).toBe(false); + expect(OnRunEnvSchema.safeParse({mode: undefined}).success).toBe(false); + }); + }); +}); diff --git a/packages/config/src/tests/cli/run.spec.ts b/packages/config/src/tests/cli/run.spec.ts new file mode 100644 index 000000000..838601138 --- /dev/null +++ b/packages/config/src/tests/cli/run.spec.ts @@ -0,0 +1,64 @@ +import {defineRun, RunFnOrObjectSchema} from '../../cli/run'; +import {OnRunContextSchema, OnRunSchema} from '../../cli/run.context'; +import {mockIdentity} from '../mocks/identity.mock'; +import {mockUserIdPrincipal} from '../mocks/principal.mock'; + +describe('run', () => { + const validOnRunObj = { + run: (_ctx: unknown) => {} + }; + + describe('RunFnOrObjectSchema', () => { + it('accepts an OnRun object', () => { + const res = RunFnOrObjectSchema.safeParse(validOnRunObj); + expect(res.success).toBe(true); + + if (res.success) { + expect(OnRunSchema.safeParse(res.data).success).toBe(true); + } + }); + + it('accepts a function returning a valid OnRun object', () => { + const fn = (_env: unknown) => validOnRunObj; + const res = RunFnOrObjectSchema.safeParse(fn); + expect(res.success).toBe(true); + }); + + it('throws when input is not a function nor a valid OnRun object', () => { + expect(() => RunFnOrObjectSchema.parse('nope')).toThrow(); + }); + + it('runtime: call the function form and validate the returned OnRun', async () => { + const fn = (_env: unknown) => ({ + run: async (ctx: unknown) => { + const parsed = OnRunContextSchema.parse(ctx); + expect(parsed.satelliteId.toText()).toBe(mockUserIdPrincipal.toText()); + } + }); + + const parsedUnion = RunFnOrObjectSchema.parse(fn); + const onRun = (parsedUnion as typeof fn)({}); + + const valid = OnRunSchema.parse(onRun); + + await valid.run({ + satelliteId: mockUserIdPrincipal, + identity: mockIdentity + }); + }); + }); + + describe('defineRun', () => { + it('returns the same OnRun object reference', () => { + const res = defineRun(validOnRunObj); + expect(res).toBe(validOnRunObj); + expect(OnRunSchema.safeParse(res).success).toBe(true); + }); + + it('returns the same function reference', () => { + const fn = (_env: unknown) => validOnRunObj; + const res = defineRun(fn); + expect(res).toBe(fn); + }); + }); +}); diff --git a/packages/config/src/tests/mocks/identity.mock.ts b/packages/config/src/tests/mocks/identity.mock.ts new file mode 100644 index 000000000..b05261692 --- /dev/null +++ b/packages/config/src/tests/mocks/identity.mock.ts @@ -0,0 +1,15 @@ +import type {Identity} from '@dfinity/agent'; +import {mockUserIdPrincipal} from './principal.mock'; + +const transformRequest = () => { + console.error( + 'It looks like the agent is trying to make a request that should have been mocked at', + new Error().stack + ); + throw new Error('Not implemented'); +}; + +export const mockIdentity = { + getPrincipal: () => mockUserIdPrincipal, + transformRequest +} as unknown as Identity; diff --git a/packages/config/src/tests/mocks/principal.mock.ts b/packages/config/src/tests/mocks/principal.mock.ts index cfd913ed3..2dddcc05b 100644 --- a/packages/config/src/tests/mocks/principal.mock.ts +++ b/packages/config/src/tests/mocks/principal.mock.ts @@ -1,2 +1,6 @@ +import {Principal} from '@dfinity/principal'; + export const mockModuleIdText = 'ucnx3-aqaaa-aaaal-ab3ea-cai'; export const mockUserIdText = 'xlmdg-vkosz-ceopx-7wtgu-g3xmd-koiyc-awqaq-7modz-zf6r6-364rh-oqe'; + +export const mockUserIdPrincipal = Principal.fromText(mockUserIdText); diff --git a/packages/config/src/tests/utils/identity.utils.spec.ts b/packages/config/src/tests/utils/identity.utils.spec.ts new file mode 100644 index 000000000..e5e0e9043 --- /dev/null +++ b/packages/config/src/tests/utils/identity.utils.spec.ts @@ -0,0 +1,46 @@ +import {StrictIdentitySchema} from '../../utils/identity.utils'; +import {mockIdentity} from '../mocks/identity.mock'; +import {mockUserIdPrincipal} from '../mocks/principal.mock'; + +describe('StrictIdentitySchema', () => { + it('accepts a valid identity object', () => { + const res = StrictIdentitySchema.safeParse(mockIdentity); + expect(res.success).toBe(true); + }); + + it('rejects if getPrincipal is missing', () => { + const res = StrictIdentitySchema.safeParse({ + transformRequest: () => {} + }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues[0].message).toBe('Invalid Identity'); + } + }); + + it('rejects if transformRequest is missing', () => { + const res = StrictIdentitySchema.safeParse({ + getPrincipal: () => mockUserIdPrincipal + }); + expect(res.success).toBe(false); + }); + + it('rejects if getPrincipal does not return a Principal', () => { + const res = StrictIdentitySchema.safeParse({ + transformRequest: () => {}, + getPrincipal: () => 'not-a-principal' + }); + expect(res.success).toBe(false); + }); + + it('rejects null and undefined', () => { + expect(StrictIdentitySchema.safeParse(null).success).toBe(false); + expect(StrictIdentitySchema.safeParse(undefined).success).toBe(false); + }); + + it('rejects primitives', () => { + expect(StrictIdentitySchema.safeParse(123).success).toBe(false); + expect(StrictIdentitySchema.safeParse('foo').success).toBe(false); + expect(StrictIdentitySchema.safeParse(true).success).toBe(false); + }); +}); diff --git a/packages/config/src/tests/utils/principal.utils.spec.ts b/packages/config/src/tests/utils/principal.utils.spec.ts index 3878fb908..6da8d2682 100644 --- a/packages/config/src/tests/utils/principal.utils.spec.ts +++ b/packages/config/src/tests/utils/principal.utils.spec.ts @@ -1,5 +1,6 @@ -import {StrictPrincipalTextSchema} from '../../utils/principal.utils'; -import {mockModuleIdText} from '../mocks/principal.mock'; +import {Principal} from '@dfinity/principal'; +import {StrictPrincipalSchema, StrictPrincipalTextSchema} from '../../utils/principal.utils'; +import {mockModuleIdText, mockUserIdPrincipal, mockUserIdText} from '../mocks/principal.mock'; describe('principal.utils', () => { describe('StrictPrincipalTextSchema', () => { @@ -18,4 +19,35 @@ describe('principal.utils', () => { } }); }); + + describe('StrictPrincipalSchema', () => { + it('accepts a Principal instance and returns a Principal', () => { + const res = StrictPrincipalSchema.safeParse(mockUserIdPrincipal); + expect(res.success).toBe(true); + if (res.success) { + expect(Principal.isPrincipal(res.data)).toBeTruthy(); + expect(res.data.toText()).toBe(mockUserIdText); + } + }); + + it('rejects a principal as string text', () => { + const res = StrictPrincipalSchema.safeParse(mockUserIdText); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues[0].message).toBe('Invalid Principal'); + expect(res.error.issues[0].path).toEqual([]); + } + }); + + it('rejects null and undefined', () => { + expect(StrictPrincipalSchema.safeParse(null).success).toBe(false); + expect(StrictPrincipalSchema.safeParse(undefined).success).toBe(false); + }); + + it('rejects non-principal objects', () => { + expect(StrictPrincipalSchema.safeParse({} as any).success).toBe(false); + expect(StrictPrincipalSchema.safeParse(123 as any).success).toBe(false); + expect(StrictPrincipalSchema.safeParse(new Uint8Array([1, 2, 3]) as any).success).toBe(false); + }); + }); }); diff --git a/packages/config/src/utils/identity.utils.ts b/packages/config/src/utils/identity.utils.ts new file mode 100644 index 000000000..53d9f2cc5 --- /dev/null +++ b/packages/config/src/utils/identity.utils.ts @@ -0,0 +1,22 @@ +import * as z from 'zod/v4'; +import {StrictPrincipalSchema} from './principal.utils'; + +/** + * Ensures an unknown object is an identity. + */ +export const StrictIdentitySchema = z + .unknown() + .refine( + (val) => + val !== undefined && + val !== null && + typeof val === 'object' && + 'transformRequest' in val && + typeof val.transformRequest === 'function' && + 'getPrincipal' in val && + typeof val.getPrincipal === 'function' && + StrictPrincipalSchema.safeParse(val.getPrincipal()).success, + { + message: 'Invalid Identity' + } + ); diff --git a/packages/config/src/utils/principal.utils.ts b/packages/config/src/utils/principal.utils.ts index 797be473e..a808af4a0 100644 --- a/packages/config/src/utils/principal.utils.ts +++ b/packages/config/src/utils/principal.utils.ts @@ -1,3 +1,4 @@ +import {Principal} from '@dfinity/principal'; import {PrincipalTextSchema} from '@dfinity/zod-schemas'; import * as z from 'zod/v4'; @@ -9,3 +10,21 @@ export const StrictPrincipalTextSchema = z .refine((val) => PrincipalTextSchema.safeParse(val).success, { message: 'Invalid textual representation of a Principal.' }); + +/** + * Ensures an unknown type is a Principal. + */ +// eslint-disable-next-line local-rules/prefer-object-params +export const StrictPrincipalSchema = z.unknown().transform((val, ctx): Principal => { + if (Principal.isPrincipal(val)) { + return Principal.from(val); + } + + ctx.issues.push({ + code: 'custom', + message: 'Invalid Principal', + input: val + }); + + return z.NEVER; +}); diff --git a/packages/config/src/utils/zod.utils.ts b/packages/config/src/utils/zod.utils.ts new file mode 100644 index 000000000..4871adca7 --- /dev/null +++ b/packages/config/src/utils/zod.utils.ts @@ -0,0 +1,8 @@ +import * as z from 'zod/v4'; + +// TODO: Workaround source: https://github.com/colinhacks/zod/issues/4143#issuecomment-2845134912 +// TODO: Duplicates the helper in @junobuild/functions. Both should be removed once we migrate to latest v4 version of Zod +export const createFunctionSchema = (schema: T) => + z.custom[0]>((fn) => + schema.implement(fn as Parameters[0]) + );