Skip to content

Commit 8bebca7

Browse files
authored
Merge pull request csg-tokyo#84 from maejima-fumika/develop/add-blue-install
Add install command.
2 parents 34e74f0 + 381d2cc commit 8bebca7

File tree

16 files changed

+389
-122
lines changed

16 files changed

+389
-122
lines changed

cli/src/commands/create-project.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { cwd } from "../core/shell";
88
import { BOARD_NAMES, isValidBoard } from "../config/board-utils";
99
import * as fs from '../core/fs';
1010

11-
const MAIN_TEMPLATE = `print('Hello world!')`;
11+
const MAIN_FILE_CONTENTS = `print('Hello world!')\n`;
12+
const GIT_IGNORE_CONTENTS = `**/dist/\n`;
1213

1314
function createProjectDir(projectName: string) {
1415
const projectDir = path.join(cwd(), projectName);
@@ -21,7 +22,12 @@ function createProjectDir(projectName: string) {
2122

2223
function createMainFile(dir: string) {
2324
const filePath = path.join(dir, DEFAULT_MAIN_FILE_NAME);
24-
fs.writeFile(filePath, MAIN_TEMPLATE);
25+
fs.writeFile(filePath, MAIN_FILE_CONTENTS);
26+
}
27+
28+
function createGitIgnore(dir: string) {
29+
const filePath = path.join(dir, '.gitignore');
30+
fs.writeFile(filePath, GIT_IGNORE_CONTENTS);
2531
}
2632

2733
export async function handleCreateProjectCommand(name: string, options: { board?: string }) {
@@ -50,6 +56,7 @@ export async function handleCreateProjectCommand(name: string, options: { board?
5056
projectConfigHandler.save(projectDir);
5157

5258
createMainFile(projectDir);
59+
createGitIgnore(projectDir);
5360

5461
logger.br();
5562
logger.success(`Success to create a new project.`);

cli/src/commands/install.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Command } from "commander";
2+
import { logger, showErrorMessages } from "../core/logger";
3+
import { LOCAL_PACKAGES_DIR, ProjectConfigHandler, PackageSource } from "../config/project-config";
4+
import { GLOBAL_BLUESCRIPT_PATH } from "../config/global-config";
5+
import { cwd, exec } from "../core/shell";
6+
import * as fs from '../core/fs';
7+
import * as path from 'path';
8+
9+
10+
class InstallationHandler {
11+
private projectConfigHandler: ProjectConfigHandler;
12+
private projectRootDir: string;
13+
private packagesDir: string;
14+
15+
constructor() {
16+
this.projectRootDir = cwd();
17+
this.projectConfigHandler = ProjectConfigHandler.load(this.projectRootDir);
18+
this.packagesDir = LOCAL_PACKAGES_DIR(this.projectRootDir);
19+
}
20+
21+
public async installAll() {
22+
this.ensurePackageDir();
23+
await this.processInstallQueue(this.projectConfigHandler.getDepenencies());
24+
}
25+
26+
public async installPackage(url: string, version?: string) {
27+
this.ensurePackageDir();
28+
const packageConfigHandler = await this.downloadPackage(url, version);
29+
const packageName = packageConfigHandler.getConfig().projectName;
30+
await this.processInstallQueue(packageConfigHandler.getDepenencies());
31+
this.projectConfigHandler.addDependency({name: packageName, url, version});
32+
this.projectConfigHandler.save(this.projectRootDir);
33+
}
34+
35+
private async processInstallQueue(queue: PackageSource[]) {
36+
const installedPackages = new Set<string>();
37+
38+
while (queue.length > 0) {
39+
const currentPkg = queue.shift();
40+
if (!currentPkg) break;
41+
if (installedPackages.has(currentPkg.name)) continue;
42+
43+
const pkgConfigHandler = await this.downloadPackage(currentPkg.url, currentPkg.version);
44+
pkgConfigHandler.checkVmVersion(this.projectConfigHandler.getConfig().vmVersion);
45+
installedPackages.add(currentPkg.name);
46+
pkgConfigHandler.getDepenencies().forEach((pkgDep) => {
47+
installedPackages.add(pkgDep.name);
48+
});
49+
}
50+
}
51+
52+
private ensurePackageDir() {
53+
if (!fs.exists(this.packagesDir)) {
54+
fs.makeDir(this.packagesDir);
55+
}
56+
}
57+
58+
private async downloadPackage(url: string, version?: string): Promise<ProjectConfigHandler> {
59+
logger.log(`Downloading from ${url}...`);
60+
const tmpDir = path.join(GLOBAL_BLUESCRIPT_PATH, 'tmp-package');
61+
const branchCmd = version ? `--branch ${version}` : '';
62+
const cmd = `git clone --depth 1 ${branchCmd} ${url} ${tmpDir}`;
63+
try {
64+
await exec(cmd, {silent: true});
65+
const gitDir = path.join(tmpDir, '.git');
66+
if (fs.exists(gitDir)) {
67+
fs.removeDir(gitDir);
68+
}
69+
const configHandler = ProjectConfigHandler.load(tmpDir);
70+
const packageName = configHandler.getConfig().projectName;
71+
const packageDir = path.join(this.packagesDir, packageName);
72+
if (fs.exists(packageDir)) {
73+
fs.removeDir(packageDir);
74+
}
75+
fs.moveDir(tmpDir, packageDir);
76+
return configHandler;
77+
} catch (error) {
78+
if (fs.exists(tmpDir)) {
79+
fs.removeDir(tmpDir);
80+
}
81+
throw new Error(`Failed to download package from '${url}'.`, {cause: error});
82+
}
83+
}
84+
}
85+
86+
87+
export async function handleInstallCommand(url: string|undefined, options: {tag?: string}) {
88+
try {
89+
const installationHandler = new InstallationHandler();
90+
if (url) {
91+
installationHandler.installPackage(url, options.tag);
92+
} else {
93+
installationHandler.installAll();
94+
}
95+
} catch (error) {
96+
const errorMessage =
97+
url ? `Failed to install ${url}.` : `Failed to install packages.`;
98+
logger.error(errorMessage);
99+
showErrorMessages(error);
100+
process.exit(1);
101+
}
102+
}
103+
104+
export function registerInstallCommand(program: Command) {
105+
program
106+
.command('install')
107+
.description('install all dependencies, or add a new package via Git URL')
108+
.argument('[git-url]', 'git repository URL to add as a dependency')
109+
.option('-t, --tag <tag>', 'git tag or branch to checkout (e.g., v1.0.0)')
110+
.action(handleInstallCommand);
111+
}

cli/src/commands/repl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ class ESP32ReplHandler extends ReplHandler {
194194
return {
195195
name: packageName,
196196
espIdfComponents: projectConfigHandler.espIdfComponents,
197-
dependencies: projectConfigHandler.dependencies,
197+
dependencies: Object.keys(projectConfigHandler.dependencies),
198198
dirs: {
199199
root,
200200
dist: DIST_DIR(root),

cli/src/commands/run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ class ESP32RunHandler extends RunHandler {
173173
return {
174174
name: packageName,
175175
espIdfComponents: projectConfigHandler.espIdfComponents,
176-
dependencies: projectConfigHandler.dependencies,
176+
dependencies: Object.keys(projectConfigHandler.dependencies),
177177
dirs: {
178178
root,
179179
dist: DIST_DIR(root),

cli/src/commands/uninstall.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Command } from "commander";
2+
import { logger, showErrorMessages } from "../core/logger";
3+
import { LOCAL_PACKAGES_DIR, ProjectConfigHandler } from "../config/project-config";
4+
import { cwd } from "../core/shell";
5+
import * as fs from '../core/fs';
6+
import * as path from 'path';
7+
8+
9+
export async function handleUninstallCommand(packageName: string) {
10+
try {
11+
const projectRootDir = cwd();
12+
const projectConfigHandler = ProjectConfigHandler.load(projectRootDir);
13+
const packageDir = path.join(LOCAL_PACKAGES_DIR(projectRootDir), packageName);
14+
if (!projectConfigHandler.dependencyExists(packageName)) {
15+
throw new Error(`Package ${packageName} is not listed in bsconfig.json dependencies.`)
16+
}
17+
if (fs.exists(packageDir)) {
18+
fs.removeDir(packageDir);
19+
}
20+
projectConfigHandler.removeDepedency(packageName);
21+
projectConfigHandler.save(projectRootDir);
22+
} catch (error) {
23+
logger.error(`Failed to uninstall ${packageName}.`);
24+
showErrorMessages(error);
25+
process.exit(1);
26+
}
27+
}
28+
29+
export function registerUninstallCommand(program: Command) {
30+
program
31+
.command('uninstall')
32+
.description('uninstall a package')
33+
.argument('<package-name>', 'Package name')
34+
.action(handleUninstallCommand);
35+
}

cli/src/config/project-config.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const baseConfigSchema = z.object({
2020
version: z.string().default(DEfAULT_PROJECT_VERSION),
2121
vmVersion: z.string().default(VM_VERSION),
2222
deviceName: z.string().default(DEFAULT_DEVICE_NAME).optional(),
23-
dependencies: z.array(z.string()).default([]),
23+
dependencies: z.record(z.string(), z.string()).default({}),
2424
runtimeDir: z.string().optional(), // for dev
2525
globalPackagesDir: z.string().optional(), // for dev
2626
});
@@ -38,6 +38,12 @@ export type ProjectConfig = z.infer<typeof projectConfigSchema>;
3838

3939
export type SpecificBoardConfig<B extends BoardName> = Extract<ProjectConfig, { boardName: B }>;
4040

41+
export type PackageSource = {
42+
name: string,
43+
url: string,
44+
version?: string
45+
};
46+
4147
export class ProjectConfigHandler {
4248
private config: ProjectConfig;
4349

@@ -85,6 +91,12 @@ export class ProjectConfigHandler {
8591
return this.config;
8692
}
8793

94+
public checkVmVersion(expected: string) {
95+
if (expected !== this.config.vmVersion) {
96+
throw new Error(`VM version of ${this.config.projectName} is not match the expected VM version(${expected}).`);
97+
}
98+
}
99+
88100
public getBoardName(): BoardName {
89101
return this.config.boardName;
90102
}
@@ -101,8 +113,8 @@ export class ProjectConfigHandler {
101113
public update(config: Partial<ProjectConfig>) {
102114
try {
103115
this.config = projectConfigSchema.parse({
104-
...config,
105-
...this.config
116+
...this.config,
117+
...config
106118
});
107119
} catch (error) {
108120
if (error instanceof z.ZodError) {
@@ -112,10 +124,36 @@ export class ProjectConfigHandler {
112124
}
113125
}
114126

115-
public addDependency(dependency: string) {
116-
const newDependencies = [...new Set([...this.config.dependencies, dependency])];
117-
this.update({
118-
dependencies: newDependencies
127+
public addDependency(source: PackageSource) {
128+
const newDependencies = {...this.config.dependencies}
129+
newDependencies[source.name] =
130+
source.version ? `${source.url}#${source.version}` : source.url;
131+
this.update({dependencies: newDependencies});
132+
}
133+
134+
public removeDepedency(name: string) {
135+
const newDependencies = {...this.config.dependencies};
136+
delete newDependencies[name];
137+
this.update({dependencies: newDependencies});
138+
}
139+
140+
public dependencyExists(name: string) {
141+
return Object.keys(this.config.dependencies).includes(name);
142+
}
143+
144+
public getDepenencies(): PackageSource[] {
145+
return Object.keys(this.config.dependencies).map(name => {
146+
const depString = this.config.dependencies[name];
147+
let url = depString;
148+
let version: string | undefined = undefined;
149+
150+
// #extract tag (git-url#commit-ish)
151+
if (depString.includes('#')) {
152+
const parts = depString.split('#');
153+
url = parts[0];
154+
version = parts[1];
155+
}
156+
return {name, url, version};
119157
});
120158
}
121159

cli/src/core/fs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export function removeDir(path: string) {
1111
fs.rmSync(path, { recursive: true });
1212
}
1313

14+
export function moveDir(from: string, to: string) {
15+
fs.renameSync(from, to);
16+
}
17+
1418
export function exists(path: string): boolean {
1519
return fs.existsSync(path);
1620
}

cli/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { registerCreateProjectCommand } from './commands/create-project';
1313
import { registerRunCommand } from './commands/run';
1414
import { registerFullcleanCommand } from './commands/board/full-clean';
1515
import { registerReplCommand } from './commands/repl';
16+
import { registerInstallCommand } from './commands/install';
17+
import { registerUninstallCommand } from './commands/uninstall';
1618

1719

1820
function registerBoardCommands(program: Command) {
@@ -42,6 +44,8 @@ function main() {
4244
registerCreateProjectCommand(command);
4345
registerRunCommand(command);
4446
registerReplCommand(command);
47+
registerInstallCommand(command);
48+
registerUninstallCommand(command);
4549

4650
command.parse(process.argv);
4751
}

cli/tests/commands/mock-helpers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export function setupMocks() {
4545
update: jest.fn(),
4646
updateWithDependency: jest.fn(),
4747
save: jest.fn(),
48+
addDependency: jest.fn(),
49+
removeDepedency: jest.fn(),
50+
dependencyExists: jest.fn(),
51+
getDepenencies: jest.fn()
4852
};
4953
jest.spyOn(ProjectConfigHandler, 'createTemplate').mockReturnValue(mockProjectConfigHandler as unknown as ProjectConfigHandler);
5054
jest.spyOn(ProjectConfigHandler, 'load').mockReturnValue(mockProjectConfigHandler as unknown as ProjectConfigHandler);

website/docs/reference/cli.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The BlueScript CLI (`blue`) is the primary tool for managing projects, setting u
88
npm install -g @bluescript/cli
99
```
1010

11-
## Project Commands
11+
## Core Commands
1212

1313
### `blue create-project`
1414

@@ -42,6 +42,50 @@ blue create-project my-app --board esp32
4242

4343
---
4444

45+
### `blue install`
46+
47+
Installs project dependencies. This command has two modes:
48+
49+
1. **Install All:** If run without arguments, it installs all dependencies listed in bsconfig.json.
50+
2. **Add Package:** If a Git URL is provided, it downloads the package, adds it to bsconfig.json, and installs it.
51+
52+
```bash
53+
blue install [git-url] [options]
54+
```
55+
56+
**Arguments:**
57+
* `<git-url>`: (Optional) The URL of the Git repository to add as a dependency.
58+
59+
**Options:**
60+
61+
| Option | Alias | Description |
62+
| :--- | :--- | :--- |
63+
| `--tag` | `-t` | Specify a git tag or branch to checkout (e.g., `v1.0.0`, `main`). |
64+
65+
**Example:**
66+
```bash
67+
# Restore all dependencies from bsconfig.json
68+
blue install
69+
70+
# Install a specific library (e.g., GPIO library)
71+
blue install https://github.com/bluescript/gpio.git
72+
73+
# Install a specific version of a library
74+
blue install https://github.com/bluescript/drivers.git --tag v2.0.0
75+
```
76+
77+
---
78+
79+
### `blue uninstall`
80+
81+
Uninstall the specified package from the current project.
82+
83+
```bash
84+
blue install [package-name]
85+
```
86+
87+
---
88+
4589
### `blue run`
4690

4791
Compiles the current project and executes it on a target device via Bluetooth.

0 commit comments

Comments
 (0)