diff --git a/package-lock.json b/package-lock.json index f82d048f..0efaab08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.22.0", "@junobuild/config": "^0.1.3", + "@junobuild/functions": "^0.0.12", "@types/node": "^22.13.10", "@types/prompts": "^2.4.9", "@types/semver": "^7.5.8", @@ -1547,6 +1548,21 @@ "integrity": "sha512-KjzwTVicmda9X4S75GN9iCa0Pt89jfgOvGT1ODZOtblk0eQd02L4PQdJICP1/bnxZdhpPrkNVY0qLVf2G7wlyA==", "license": "MIT" }, + "node_modules/@junobuild/functions": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@junobuild/functions/-/functions-0.0.12.tgz", + "integrity": "sha512-hG5iHE+fDkWTWmtpnTKDfKM/pCIxlAgYBYujEnU8YD7Me+8Q5eYGSkWnMe/gtb7ua48qqkH84SyNzdCTrR6GxQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@dfinity/agent": "^2.3.0", + "@dfinity/candid": "^2.3.0", + "@dfinity/identity": "^2.3.0", + "@dfinity/principal": "^2.3.0", + "@dfinity/utils": "^2", + "zod": "^3" + } + }, "node_modules/@junobuild/storage": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@junobuild/storage/-/storage-0.0.8.tgz", @@ -7041,6 +7057,17 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -7896,6 +7923,13 @@ "resolved": "https://registry.npmjs.org/@junobuild/errors/-/errors-0.0.2.tgz", "integrity": "sha512-KjzwTVicmda9X4S75GN9iCa0Pt89jfgOvGT1ODZOtblk0eQd02L4PQdJICP1/bnxZdhpPrkNVY0qLVf2G7wlyA==" }, + "@junobuild/functions": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@junobuild/functions/-/functions-0.0.12.tgz", + "integrity": "sha512-hG5iHE+fDkWTWmtpnTKDfKM/pCIxlAgYBYujEnU8YD7Me+8Q5eYGSkWnMe/gtb7ua48qqkH84SyNzdCTrR6GxQ==", + "dev": true, + "requires": {} + }, "@junobuild/storage": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@junobuild/storage/-/storage-0.0.8.tgz", @@ -11363,6 +11397,13 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "dev": true, + "peer": true } } } diff --git a/package.json b/package.json index 9dbe13f6..df87c7ef 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.22.0", "@junobuild/config": "^0.1.3", + "@junobuild/functions": "^0.0.12", "@types/node": "^22.13.10", "@types/prompts": "^2.4.9", "@types/semver": "^7.5.8", diff --git a/src/commands/dev.ts b/src/commands/dev.ts index 0ff0caa7..da3eb9e8 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -1,16 +1,17 @@ import {red} from 'kleur'; import {logHelpDevBuild} from '../help/dev.build.help'; +import {logHelpDevEject} from '../help/dev.eject.help'; import {logHelpDev} from '../help/dev.help'; import {build} from '../services/build/build.services'; import {start, stop} from '../services/docker.services'; -import {eject} from '../services/eject.services'; +import {eject} from '../services/eject/eject.services'; export const dev = async (args?: string[]) => { const [subCommand] = args ?? []; switch (subCommand) { case 'eject': - await eject(); + await eject(args); break; case 'build': await build(args); @@ -34,6 +35,9 @@ export const helpDev = (args?: string[]) => { case 'build': logHelpDevBuild(args); break; + case 'eject': + logHelpDevEject(args); + break; default: logHelpDev(args); } diff --git a/src/constants/dev.constants.ts b/src/constants/dev.constants.ts index 7d98f0d7..d208b4fe 100644 --- a/src/constants/dev.constants.ts +++ b/src/constants/dev.constants.ts @@ -9,8 +9,8 @@ export const DEVELOPER_PROJECT_SATELLITE_DECLARATIONS_PATH = join( ); export const CARGO_TOML = 'Cargo.toml'; -export const INDEX_TS = "index.ts"; -export const INDEX_MJS = "index.mjs"; +export const INDEX_TS = 'index.ts'; +export const INDEX_MJS = 'index.mjs'; export const DEVELOPER_PROJECT_SATELLITE_CARGO_TOML = join( DEVELOPER_PROJECT_SATELLITE_PATH, @@ -30,11 +30,14 @@ const TEMPLATE_PATH = '../templates/eject'; export const RUST_TEMPLATE_PATH = join(TEMPLATE_PATH, 'rust'); export const RUST_TEMPLATE_SATELLITE_PATH = join(RUST_TEMPLATE_PATH, 'src', 'satellite'); +export const TS_TEMPLATE_PATH = join(TEMPLATE_PATH, 'typescript'); +export const MJS_TEMPLATE_PATH = join(TEMPLATE_PATH, 'javascript'); + export const RUST_MIN_VERSION = '1.70.0'; export const IC_WASM_MIN_VERSION = '0.8.5'; export const DOCKER_MIN_VERSION = '24.0.0'; export const DEPLOY_LOCAL_REPLICA_PATH = join(process.cwd(), 'target', 'deploy'); -export const SPUTNIK_INDEX_MJS = "sputnik.index.mjs"; +export const SPUTNIK_INDEX_MJS = 'sputnik.index.mjs'; export const DEPLOY_SPUTNIK_PATH = join(DEPLOY_LOCAL_REPLICA_PATH, SPUTNIK_INDEX_MJS); diff --git a/src/help/dev.build.help.ts b/src/help/dev.build.help.ts index df4f63cf..51a31b5e 100644 --- a/src/help/dev.build.help.ts +++ b/src/help/dev.build.help.ts @@ -2,7 +2,7 @@ import {cyan, green, magenta, yellow} from 'kleur'; import {helpOutput} from './common.help'; import {TITLE} from './help'; -export const DEV_BUILD_DESCRIPTION = 'Build your serverless functions.'; +const DEV_BUILD_DESCRIPTION = 'Build your serverless functions.'; const usage = `Usage: ${green('juno')} ${cyan('dev')} ${magenta('build')} ${yellow('[options]')} @@ -15,7 +15,7 @@ Notes: - If no language is provided, the CLI attempts to determine the appropriate build. - Language can be shortened to ${magenta('rs')} for Rust, ${magenta('ts')} for TypeScript and ${magenta('js')} for JavaScript. -- The path option maps to ${magenta('--manifest-path')} for Rust (Cargo) or to the source file for TypeScript and JavaScript (e.g. ${magenta('index.ts')} or ${magenta('index.mjs')}) .`; +- The path option maps to ${magenta('--manifest-path')} for Rust (Cargo) or to the source file for TypeScript and JavaScript (e.g. ${magenta('index.ts')} or ${magenta('index.mjs')}).`; const doc = `${DEV_BUILD_DESCRIPTION} diff --git a/src/help/dev.eject.help.ts b/src/help/dev.eject.help.ts new file mode 100644 index 00000000..844d908b --- /dev/null +++ b/src/help/dev.eject.help.ts @@ -0,0 +1,34 @@ +import {cyan, green, magenta, yellow} from 'kleur'; +import {helpOutput} from './common.help'; +import {TITLE} from './help'; + +export const DEV_EJECT_DESCRIPTION = + 'Generate the required files to begin developing serverless functions in your project.'; + +const usage = `Usage: ${green('juno')} ${cyan('dev')} ${magenta('eject')} ${yellow('[options]')} + +Options: + ${yellow('-l, --lang')} Specify the language for building the serverless functions: ${magenta('rust')}, ${magenta('typescript')} or ${magenta('javascript')}. + ${yellow('-h, --help')} Output usage information. + +Notes: + +- Language can be shortened to ${magenta('rs')} for Rust, ${magenta('ts')} for TypeScript and ${magenta('js')} for JavaScript.`; + +const doc = `${DEV_EJECT_DESCRIPTION} + +\`\`\`bash +${usage} +\`\`\` +`; + +const help = `${TITLE} + +${DEV_EJECT_DESCRIPTION} + +${usage} +`; + +export const logHelpDevEject = (args?: string[]) => { + console.log(helpOutput(args) === 'doc' ? doc : help); +}; diff --git a/src/services/eject.services.ts b/src/services/eject.services.ts deleted file mode 100644 index dfb39114..00000000 --- a/src/services/eject.services.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {cyan, green, magenta, yellow} from 'kleur'; -import {mkdir} from 'node:fs/promises'; -import {join} from 'node:path'; -import { - DEVELOPER_PROJECT_SATELLITE_PATH, - RUST_TEMPLATE_PATH, - TEMPLATE_SATELLITE_PATH -} from '../constants/dev.constants'; -import {helpDevContinue} from '../help/dev.help'; -import {copySatelliteDid} from '../utils/did.utils'; -import {checkRustVersion} from '../utils/env.utils'; -import {copyTemplateFile} from '../utils/fs.utils'; - -export const eject = async () => { - const {valid} = await checkRustVersion(); - - if (valid === 'error' || !valid) { - return; - } - - await copyTemplateFile({ - template: 'Cargo.toml', - sourceFolder: RUST_TEMPLATE_PATH, - destinationFolder: '.' - }); - - const devProjectSrcPath = join(DEVELOPER_PROJECT_SATELLITE_PATH, 'src'); - - await mkdir(devProjectSrcPath, {recursive: true}); - - await copyTemplateFile({ - template: 'Cargo.toml', - sourceFolder: TEMPLATE_SATELLITE_PATH, - destinationFolder: DEVELOPER_PROJECT_SATELLITE_PATH - }); - - await copyTemplateFile({ - template: 'lib.rs', - sourceFolder: join(TEMPLATE_SATELLITE_PATH, 'src'), - destinationFolder: devProjectSrcPath - }); - - await copySatelliteDid(); - - console.log(success({src: DEVELOPER_PROJECT_SATELLITE_PATH})); -}; - -export const success = ({src}: {src: string}): string => ` -🚀 Satellite successfully ejected! - -Your Rust serverless function has been generated. -You can now start coding in: ${yellow(src)} - -Useful ${green('juno')} ${cyan('dev')} ${magenta('')} to continue with: - -Subcommands: - ${helpDevContinue} -`; diff --git a/src/services/eject/eject.javascript.services.ts b/src/services/eject/eject.javascript.services.ts new file mode 100644 index 00000000..a254eb13 --- /dev/null +++ b/src/services/eject/eject.javascript.services.ts @@ -0,0 +1,88 @@ +import {execute} from '@junobuild/cli-tools'; +import {magenta} from 'kleur'; +import {mkdir, readFile} from 'node:fs/promises'; +import {join} from 'node:path'; +import { + DEVELOPER_PROJECT_SATELLITE_PATH, + INDEX_MJS, + INDEX_TS, + MJS_TEMPLATE_PATH, + TS_TEMPLATE_PATH +} from '../../constants/dev.constants'; +import {copyTemplateFile} from '../../utils/fs.utils'; +import {detectPackageManager} from '../../utils/pm.utils'; +import {confirmAndExit} from '../../utils/prompt.utils'; + +export const ejectTypeScript = async () => { + await eject({lang: 'ts'}); +}; + +export const ejectJavaScript = async () => { + await eject({lang: 'mjs'}); +}; + +const eject = async ({lang}: {lang: 'ts' | 'mjs'}) => { + await installFunctionsLib(); + + await createTargetDir(); + + await copyTemplateIndex({lang}); + + if (lang === 'ts') { + await copyTemplateTsConfig(); + } +}; + +const copyTemplateIndex = async ({lang}: {lang: 'ts' | 'mjs'}) => { + await copyTemplateFile({ + template: lang === 'mjs' ? INDEX_MJS : INDEX_TS, + sourceFolder: lang === 'mjs' ? MJS_TEMPLATE_PATH : TS_TEMPLATE_PATH, + destinationFolder: DEVELOPER_PROJECT_SATELLITE_PATH + }); +}; + +const copyTemplateTsConfig = async () => { + await copyTemplateFile({ + template: 'tsconfig.json', + sourceFolder: TS_TEMPLATE_PATH, + destinationFolder: DEVELOPER_PROJECT_SATELLITE_PATH + }); +}; + +const createTargetDir = async () => { + const devProjectSrcPath = join(DEVELOPER_PROJECT_SATELLITE_PATH); + await mkdir(devProjectSrcPath, {recursive: true}); +}; + +const installFunctionsLib = async () => { + const functionsAlreadyInstalled = await hasFunctionsLib(); + + if (functionsAlreadyInstalled) { + return; + } + + await confirmAndExit( + `The ${magenta( + '@junobuild/functions' + )} library is required to develop serverless functions. Install it now?` + ); + + const pm = detectPackageManager(); + + await execute({ + command: pm ?? 'npm', + args: [pm === 'npm' ? 'i' : 'add', '@junobuild/functions'] + }); +}; + +const hasFunctionsLib = async (): Promise => { + try { + const packageJson = await readFile(join(process.cwd(), 'package.json'), 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const {dependencies} = JSON.parse(packageJson) as {dependencies?: Record}; + return Object.keys(dependencies ?? {}).includes('@junobuild/functions'); + } catch (_err: unknown) { + // This should not block the developer therefore we fallback to asking for installing the library. + return false; + } +}; diff --git a/src/services/eject/eject.rust.services.ts b/src/services/eject/eject.rust.services.ts new file mode 100644 index 00000000..dd39bb07 --- /dev/null +++ b/src/services/eject/eject.rust.services.ts @@ -0,0 +1,43 @@ +import {mkdir} from 'node:fs/promises'; +import {join} from 'node:path'; +import { + CARGO_TOML, + DEVELOPER_PROJECT_SATELLITE_PATH, + RUST_TEMPLATE_PATH, + RUST_TEMPLATE_SATELLITE_PATH +} from '../../constants/dev.constants'; +import {copySatelliteDid} from '../../utils/did.utils'; +import {checkRustVersion} from '../../utils/env.utils'; +import {copyTemplateFile} from '../../utils/fs.utils'; + +export const ejectRust = async () => { + const {valid} = await checkRustVersion(); + + if (valid === 'error' || !valid) { + return; + } + + await copyTemplateFile({ + template: CARGO_TOML, + sourceFolder: RUST_TEMPLATE_PATH, + destinationFolder: '.' + }); + + const devProjectSrcPath = join(DEVELOPER_PROJECT_SATELLITE_PATH, 'src'); + + await mkdir(devProjectSrcPath, {recursive: true}); + + await copyTemplateFile({ + template: CARGO_TOML, + sourceFolder: RUST_TEMPLATE_SATELLITE_PATH, + destinationFolder: DEVELOPER_PROJECT_SATELLITE_PATH + }); + + await copyTemplateFile({ + template: 'lib.rs', + sourceFolder: join(RUST_TEMPLATE_SATELLITE_PATH, 'src'), + destinationFolder: devProjectSrcPath + }); + + await copySatelliteDid(); +}; diff --git a/src/services/eject/eject.services.ts b/src/services/eject/eject.services.ts new file mode 100644 index 00000000..3466a3f4 --- /dev/null +++ b/src/services/eject/eject.services.ts @@ -0,0 +1,94 @@ +import {notEmptyString} from '@dfinity/utils'; +import {assertAnswerCtrlC, nextArg} from '@junobuild/cli-tools'; +import {cyan, green, magenta, red, yellow} from 'kleur'; +import prompts from 'prompts'; +import {DEVELOPER_PROJECT_SATELLITE_PATH} from '../../constants/dev.constants'; +import {helpDevContinue} from '../../help/dev.help'; +import {ejectJavaScript, ejectTypeScript} from './eject.javascript.services'; +import {ejectRust} from './eject.rust.services'; + +type Lang = 'ts' | 'mjs' | 'rs'; + +export const eject = async (args?: string[]) => { + const cmdLang = nextArg({args, option: '-l'}) ?? nextArg({args, option: '--lang'}); + + if (notEmptyString(cmdLang)) { + await ejectWithCmdLang({lang: cmdLang}); + return; + } + + await promptLangAndEject(); +}; + +const ejectWithCmdLang = async ({lang}: {lang: string | undefined}) => { + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check + switch (lang?.toLowerCase()) { + case 'rs': + case 'rust': + await ejectRust(); + break; + case 'ts': + case 'typescript': + await ejectTypeScript(); + break; + case 'js': + case 'javascript': + unsupportedLang(); + return; + } + + console.log(success()); +}; + +const promptLangAndEject = async () => { + const {lang} = await selectLang(); + + switch (lang) { + case 'rs': + await ejectRust(); + break; + case 'ts': + await ejectTypeScript(); + break; + case 'mjs': + await ejectJavaScript(); + break; + } + + console.log(success()); +}; + +const unsupportedLang = () => { + console.log(red('Unsupported language. No serverless function was generated.')); + process.exit(1); +}; + +const selectLang = async (): Promise<{lang: Lang}> => { + const {lang}: {lang: Lang} = await prompts({ + type: 'select', + name: 'lang', + message: 'What language do you want to use for your serverless function?', + choices: [ + {title: 'Rust', value: 'rs'}, + {title: 'TypeScript', value: 'ts'}, + {title: 'JavaScript', value: 'mjs'} + ], + initial: 0 + }); + + assertAnswerCtrlC(lang); + + return {lang}; +}; + +export const success = (): string => ` +🚀 Satellite successfully ejected! + +The serverless function has been generated. +You can now start coding in: ${yellow(DEVELOPER_PROJECT_SATELLITE_PATH)} + +Useful ${green('juno')} ${cyan('dev')} ${magenta('')} to continue with: + +Subcommands: + ${helpDevContinue} +`; diff --git a/src/utils/did.utils.ts b/src/utils/did.utils.ts index ca10cf74..ee215184 100644 --- a/src/utils/did.utils.ts +++ b/src/utils/did.utils.ts @@ -1,18 +1,21 @@ import { DEVELOPER_PROJECT_SATELLITE_PATH, - TEMPLATE_SATELLITE_PATH + RUST_TEMPLATE_SATELLITE_PATH } from '../constants/dev.constants'; import {copyTemplateFile, readTemplateFile} from './fs.utils'; export const copySatelliteDid = async (overwrite?: boolean) => { await copyTemplateFile({ template: 'satellite.did', - sourceFolder: TEMPLATE_SATELLITE_PATH, + sourceFolder: RUST_TEMPLATE_SATELLITE_PATH, destinationFolder: DEVELOPER_PROJECT_SATELLITE_PATH, overwrite }); }; export const readSatelliteDid = async (): Promise => { - return await readTemplateFile({template: 'satellite.did', sourceFolder: TEMPLATE_SATELLITE_PATH}); + return await readTemplateFile({ + template: 'satellite.did', + sourceFolder: RUST_TEMPLATE_SATELLITE_PATH + }); }; diff --git a/templates/eject/javascript/index.mjs b/templates/eject/javascript/index.mjs new file mode 100644 index 00000000..544a4a5a --- /dev/null +++ b/templates/eject/javascript/index.mjs @@ -0,0 +1,15 @@ +import {defineAssert, defineHook} from '@junobuild/functions'; + +// All the available hooks and assertions for your Datastore and Storage are scaffolded by default in this module. +// However, if you don’t have to implement all of them, for example to improve readability or reduce unnecessary logic, +// you can selectively delete the features you do not need. + +export const assertSetDoc = defineAssert({ + collections: [], + assert: (context) => {} +}); + +export const onSetDoc = defineHook({ + collections: [], + run: async (context) => {} +}); diff --git a/templates/eject/typescript/index.ts b/templates/eject/typescript/index.ts new file mode 100644 index 00000000..e612ab19 --- /dev/null +++ b/templates/eject/typescript/index.ts @@ -0,0 +1,15 @@ +import {type AssertSetDoc, defineAssert, defineHook, type OnSetDoc} from '@junobuild/functions'; + +// All the available hooks and assertions for your Datastore and Storage are scaffolded by default in this module. +// However, if you don’t have to implement all of them, for example to improve readability or reduce unnecessary logic, +// you can selectively delete the features you do not need. + +export const assertSetDoc = defineAssert({ + collections: [], + assert: (context) => {} +}); + +export const onSetDoc = defineHook({ + collections: [], + run: async (context) => {} +}); diff --git a/templates/eject/typescript/tsconfig.json b/templates/eject/typescript/tsconfig.json new file mode 100644 index 00000000..24eafa39 --- /dev/null +++ b/templates/eject/typescript/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "moduleDetection": "force", + "module": "ES2020", + "target": "ES2020", + "lib": ["ES2020", "dom"], + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "isolatedModules": true, + "moduleResolution": "bundler", + "outDir": "../../target/deploy" + }, + "include": ["**/*.ts"] +}