Skip to content

Commit f109ede

Browse files
authored
projects scaffold command (#139)
* projects scaffold command
1 parent 6fd0f74 commit f109ede

6 files changed

Lines changed: 368 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mondaycom/apps-cli",
3-
"version": "4.8.1",
3+
"version": "4.9.0",
44
"description": "A cli tool to manage apps (and monday-code projects) in monday.com",
55
"author": "monday.com Apps Team",
66
"type": "module",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { PROJECT_TEMPLATES } from 'consts/scaffold';
2+
3+
describe('app:scaffold command', () => {
4+
describe('PROJECT_TEMPLATES', () => {
5+
it('should have valid project templates', () => {
6+
expect(PROJECT_TEMPLATES).toBeDefined();
7+
expect(PROJECT_TEMPLATES.length).toBeGreaterThan(0);
8+
expect(PROJECT_TEMPLATES[0]).toHaveProperty('name');
9+
});
10+
11+
it('should have templates with setup documentation', () => {
12+
const template = PROJECT_TEMPLATES.find(p => p.name === 'slack-node');
13+
expect(template).toBeDefined();
14+
expect(template?.openSetupMd).toBe(true);
15+
});
16+
});
17+
});

src/commands/app/scaffold.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import * as path from 'node:path';
2+
3+
import { Args, Flags } from '@oclif/core';
4+
import { Listr } from 'listr2';
5+
6+
import { BaseCommand } from 'commands-base/base-command';
7+
import { PROJECT_TEMPLATES } from 'consts/scaffold';
8+
import { PromptService } from 'services/prompt-service';
9+
import {
10+
downloadTemplateTask,
11+
editEnvFileTask,
12+
installDependenciesTask,
13+
openSetupFileTask,
14+
runProjectTask,
15+
validateDestination,
16+
} from 'services/scaffold-service';
17+
import { ProjectTemplate, ScaffoldTaskContext } from 'types/commands/scaffold';
18+
import logger from 'utils/logger';
19+
20+
export default class AppScaffold extends BaseCommand {
21+
static description =
22+
'Scaffold a monday app from a template, install dependencies, and start the project automatically.';
23+
24+
static examples = [
25+
'<%= config.bin %> <%= command.id %>',
26+
'<%= config.bin %> <%= command.id %> ./my-app quickstart-react',
27+
'<%= config.bin %> <%= command.id %> ./my-app slack-node --signingSecret YOUR_SECRET',
28+
'<%= config.bin %> <%= command.id %> ./my-app word-cloud --command dev',
29+
];
30+
31+
static args = {
32+
destination: Args.string({
33+
description: 'The destination directory for the scaffolded project',
34+
required: false,
35+
}),
36+
project: Args.string({
37+
description: 'The name of the template project to scaffold',
38+
required: false,
39+
}),
40+
};
41+
42+
static flags = AppScaffold.serializeFlags({
43+
signingSecret: Flags.string({
44+
char: 's',
45+
description: 'monday signing secret (for .env configuration)',
46+
required: false,
47+
}),
48+
command: Flags.string({
49+
char: 'c',
50+
description: 'npm script command to run after installation (default: start)',
51+
required: false,
52+
default: 'start',
53+
}),
54+
});
55+
56+
DEBUG_TAG = 'app_scaffold';
57+
58+
public async run(): Promise<void> {
59+
try {
60+
const { args, flags } = await this.parse(AppScaffold);
61+
62+
// Get project
63+
let project: ProjectTemplate;
64+
if (args.project) {
65+
const foundProject = PROJECT_TEMPLATES.find(p => p.name === args.project);
66+
if (!foundProject) {
67+
throw new Error(
68+
`Project "${args.project}" not found. Available projects: ${PROJECT_TEMPLATES.map(p => p.name).join(', ')}`,
69+
);
70+
}
71+
72+
project = foundProject;
73+
} else {
74+
const projectName = await PromptService.promptList(
75+
'Which project do you want to start from?',
76+
PROJECT_TEMPLATES.map(p => p.name),
77+
);
78+
project = PROJECT_TEMPLATES.find(p => p.name === projectName)!;
79+
}
80+
81+
// Get destination
82+
let destination: string;
83+
if (args.destination) {
84+
destination = path.resolve(args.destination);
85+
} else {
86+
const destInput = await PromptService.promptInput('Choose destination folder', false);
87+
destination = path.resolve(destInput || './');
88+
}
89+
90+
// Validate destination
91+
await validateDestination(destination);
92+
93+
// Get signing secret if needed
94+
let signingSecret = flags.signingSecret;
95+
if (!signingSecret && project.isWithSigningSecret) {
96+
signingSecret = await PromptService.promptInput(
97+
'Enter signing secret (optional, press Enter to skip)',
98+
false,
99+
true,
100+
);
101+
}
102+
103+
const projectPath = path.join(destination, project.name);
104+
105+
// Use command flag (defaults to 'start')
106+
const startCommand = flags.command;
107+
108+
this.preparePrintCommand(this, flags, args);
109+
110+
const context: ScaffoldTaskContext = {
111+
project,
112+
destination,
113+
signingSecret,
114+
projectPath,
115+
startCommand,
116+
};
117+
118+
await this.executeScaffold(context);
119+
} catch (error: any) {
120+
logger.debug(error, this.DEBUG_TAG);
121+
throw error;
122+
}
123+
}
124+
125+
private async executeScaffold(ctx: ScaffoldTaskContext): Promise<void> {
126+
const tasks = new Listr<ScaffoldTaskContext>([
127+
{ title: 'Downloading template', task: downloadTemplateTask },
128+
{ title: 'Configuring environment', task: editEnvFileTask },
129+
{
130+
title: 'Opening setup documentation',
131+
task: openSetupFileTask,
132+
enabled: () => Boolean(ctx.project.openSetupMd),
133+
},
134+
{ title: 'Installing dependencies', task: installDependenciesTask },
135+
{ title: 'Starting project', task: runProjectTask },
136+
]);
137+
138+
await tasks.run(ctx);
139+
logger.success(
140+
`Project is running at: ${ctx.projectPath}\n` +
141+
`Running command: npm run ${ctx.startCommand}\n` +
142+
`To stop: Press Ctrl+C\n` +
143+
`To run manually later:\n` +
144+
` cd ${ctx.project.name}\n` +
145+
` npm run ${ctx.startCommand}`,
146+
);
147+
}
148+
}

src/consts/scaffold.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ProjectTemplate } from 'types/commands/scaffold';
2+
3+
export const MONDAY_GITHUB_REPO = 'mondaycom/welcome-apps';
4+
export const MONDAY_GITHUB_REPO_URL = `https://github.com/${MONDAY_GITHUB_REPO}`;
5+
export const MONDAY_GITHUB_REPO_BRANCH = 'master';
6+
7+
export const PROJECT_TEMPLATES: ProjectTemplate[] = [
8+
{ name: 'quickstart-react' },
9+
{ name: 'quickstart-workdocs' },
10+
{ name: 'slack-node', isWithSigningSecret: true, openSetupMd: true },
11+
{ name: 'word-cloud' },
12+
{ name: 'docs-viewer' },
13+
{ name: 'workspace-view-app' },
14+
];

src/services/scaffold-service.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { spawn } from 'node:child_process';
2+
import * as fs from 'node:fs';
3+
import * as os from 'node:os';
4+
import * as path from 'node:path';
5+
6+
import { ensureDir } from 'fs-extra';
7+
import { ListrTaskWrapper } from 'listr2';
8+
9+
import { MONDAY_GITHUB_REPO, MONDAY_GITHUB_REPO_BRANCH, MONDAY_GITHUB_REPO_URL } from 'consts/scaffold';
10+
import { cloneFolderFromGitRepo } from 'services/git-service';
11+
import { ScaffoldTaskContext } from 'types/commands/scaffold';
12+
import logger from 'utils/logger';
13+
14+
const DEBUG_TAG = 'scaffold_service';
15+
const isWindows = () => process.platform === 'win32';
16+
const npmCmd = isWindows() ? 'npm.cmd' : 'npm';
17+
18+
export const downloadTemplateTask = async (
19+
ctx: ScaffoldTaskContext,
20+
task: ListrTaskWrapper<ScaffoldTaskContext, any>,
21+
) => {
22+
const output = (data: string) => {
23+
task.output = data;
24+
};
25+
26+
task.title = 'Downloading template from GitHub';
27+
const gitRepoUrl = `https://github.com/${MONDAY_GITHUB_REPO}`;
28+
const folderPath = `apps/${ctx.project.name}`;
29+
30+
await cloneFolderFromGitRepo(gitRepoUrl, folderPath, MONDAY_GITHUB_REPO_BRANCH, ctx.projectPath, output);
31+
task.title = 'Template downloaded successfully';
32+
};
33+
34+
export const editEnvFileTask = async (ctx: ScaffoldTaskContext, task: ListrTaskWrapper<ScaffoldTaskContext, any>) => {
35+
task.title = 'Configuring environment variables';
36+
const filePath = path.join(ctx.projectPath, '.env');
37+
38+
if (!fs.existsSync(filePath)) {
39+
task.skip('.env file not found, skipping configuration');
40+
return;
41+
}
42+
43+
if (!ctx.signingSecret) {
44+
task.skip('No signing secret provided, skipping .env configuration');
45+
return;
46+
}
47+
48+
try {
49+
let envLines = fs.readFileSync(filePath, 'utf8').replaceAll('\r\n', '\n').split('\n');
50+
51+
// Update MONDAY_SIGNING_SECRET if provided
52+
envLines = envLines.map(line =>
53+
line.startsWith('MONDAY_SIGNING_SECRET=') ? `MONDAY_SIGNING_SECRET=${ctx.signingSecret}` : line,
54+
);
55+
56+
fs.writeFileSync(filePath, envLines.join(os.EOL), 'utf8');
57+
task.title = 'Environment variables configured';
58+
} catch (error) {
59+
logger.debug(error, DEBUG_TAG);
60+
task.skip('Failed to configure environment variables');
61+
}
62+
};
63+
64+
export const openSetupFileTask = async (ctx: ScaffoldTaskContext, task: ListrTaskWrapper<ScaffoldTaskContext, any>) => {
65+
if (!ctx.project.openSetupMd) {
66+
task.skip('No setup documentation for this template');
67+
return;
68+
}
69+
70+
task.title = 'Opening setup documentation';
71+
const setupUrl = `${MONDAY_GITHUB_REPO_URL}/blob/${MONDAY_GITHUB_REPO_BRANCH}/apps/${ctx.project.name}/SETUP.md`;
72+
73+
try {
74+
// Map platform identifiers to their corresponding open commands
75+
const platformCommands: Record<string, string> = {
76+
darwin: 'open',
77+
win32: 'start',
78+
linux: 'xdg-open',
79+
};
80+
81+
const command = platformCommands[process.platform] ?? platformCommands.linux;
82+
spawn(command, [setupUrl], { detached: true, stdio: 'ignore' }).unref();
83+
task.title = `Setup documentation opened in browser`;
84+
} catch (error) {
85+
logger.debug(error, DEBUG_TAG);
86+
task.skip(`Setup URL: ${setupUrl}`);
87+
}
88+
};
89+
90+
export const installDependenciesTask = async (
91+
ctx: ScaffoldTaskContext,
92+
task: ListrTaskWrapper<ScaffoldTaskContext, any>,
93+
) => {
94+
task.title = 'Installing npm packages';
95+
96+
return new Promise<void>((resolve, reject) => {
97+
const installProcess = spawn(npmCmd, ['install'], {
98+
cwd: ctx.projectPath,
99+
shell: true,
100+
stdio: ['ignore', 'pipe', 'pipe'],
101+
});
102+
103+
let errorOutput = '';
104+
105+
installProcess.stderr?.on('data', (data: Buffer) => {
106+
errorOutput += data.toString();
107+
});
108+
109+
installProcess.on('exit', code => {
110+
if (code === 0) {
111+
task.title = 'Dependencies installed successfully';
112+
resolve();
113+
} else {
114+
logger.debug(`npm install failed with code ${code}: ${errorOutput}`, DEBUG_TAG);
115+
reject(new Error(`Failed to install dependencies (exit code ${code})`));
116+
}
117+
});
118+
119+
installProcess.on('error', error => {
120+
logger.debug(error, DEBUG_TAG);
121+
reject(new Error(`Failed to run npm install: ${error.message}`));
122+
});
123+
});
124+
};
125+
126+
export const runProjectTask = async (ctx: ScaffoldTaskContext, task: ListrTaskWrapper<ScaffoldTaskContext, any>) => {
127+
task.title = 'Starting the project';
128+
129+
return new Promise<void>((resolve, reject) => {
130+
const startProcess = spawn(npmCmd, ['run', ctx.startCommand], {
131+
cwd: ctx.projectPath,
132+
shell: true,
133+
stdio: 'inherit',
134+
});
135+
136+
// Handle process cleanup on exit
137+
const cleanup = () => {
138+
if (!startProcess.killed) {
139+
startProcess.kill('SIGTERM');
140+
}
141+
};
142+
143+
process.on('SIGINT', cleanup);
144+
process.on('SIGTERM', cleanup);
145+
process.on('exit', cleanup);
146+
147+
startProcess.on('exit', code => {
148+
if (code === 0) {
149+
task.title = 'Project started successfully';
150+
resolve();
151+
} else if (code !== null) {
152+
reject(new Error(`Project exited with code ${code}`));
153+
}
154+
});
155+
156+
startProcess.on('error', error => {
157+
logger.debug(error, DEBUG_TAG);
158+
reject(new Error(`Failed to start project: ${error.message}`));
159+
});
160+
161+
// Resolve after a short delay to let the process start
162+
setTimeout(() => {
163+
task.title = `Project is running (npm run ${ctx.startCommand})`;
164+
resolve();
165+
}, 2000);
166+
});
167+
};
168+
169+
export const validateDestination = async (destination: string): Promise<void> => {
170+
try {
171+
await ensureDir(destination);
172+
} catch {
173+
throw new Error(`Invalid destination directory: ${destination}`);
174+
}
175+
};

src/types/commands/scaffold.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type ProjectTemplate = {
2+
name: string;
3+
isWithSigningSecret?: boolean;
4+
openSetupMd?: boolean;
5+
};
6+
7+
export type ScaffoldTaskContext = {
8+
project: ProjectTemplate;
9+
destination: string;
10+
signingSecret?: string;
11+
projectPath: string;
12+
startCommand: string;
13+
};

0 commit comments

Comments
 (0)