Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions platformio/project/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import json
import os
import shutil

import click

Expand Down Expand Up @@ -45,13 +46,28 @@ def validate_boards(ctx, param, value): # pylint: disable=unused-argument
return value


def validate_template_dir(ctx, param, value): # pylint: disable=unused-argument
if not value:
return value
value = os.path.abspath(os.path.normpath(value))
if not os.access(value, os.R_OK):
raise click.BadParameter("`%s` is not readable" % value)
Comment on lines +53 to +54

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate_template_dir() only checks os.R_OK. For directories on POSIX, read permission alone isn’t sufficient to traverse/copy contents (needs execute/search permission too), so os.walk()/copy2() can still fail with PermissionError after validation passes. Consider validating os.X_OK as well (and/or handle os.walk(..., onerror=...) to surface a clear Click error).

Suggested change
if not os.access(value, os.R_OK):
raise click.BadParameter("`%s` is not readable" % value)
if not os.access(value, os.R_OK | os.X_OK):
raise click.BadParameter("`%s` is not readable or traversable" % value)

Copilot uses AI. Check for mistakes.
return value


@click.command("init", short_help="Initialize a project or update existing")
@click.option(
"--project-dir",
"-d",
default=os.getcwd,
type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True),
)
@click.option(
"--template-dir",
type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True),
callback=validate_template_dir,
help="Copy files from this directory into a new project",

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --template-dir help text says it will “Copy files … into a new project”, but the implementation (a) only applies when is_new_project is true and (b) silently skips any destination files that already exist (including the default scaffold files created earlier). Please either document these constraints in the option help (e.g., “only for new projects; does not overwrite existing files”) or adjust behavior to match the description.

Suggested change
help="Copy files from this directory into a new project",
help=(
"Copy files from this directory into a new project only; "
"existing files are not overwritten"
),

Copilot uses AI. Check for mistakes.
)
@click.option(
"-b", "--board", "boards", multiple=True, metavar="ID", callback=validate_boards
)
Expand All @@ -70,6 +86,7 @@ def validate_boards(ctx, param, value): # pylint: disable=unused-argument
@click.option("-s", "--silent", is_flag=True)
def project_init_cmd( # pylint: disable=too-many-positional-arguments
project_dir,
template_dir,
boards,
ide,
environment,
Expand All @@ -80,11 +97,12 @@ def project_init_cmd( # pylint: disable=too-many-positional-arguments
silent,
):
project_dir = os.path.abspath(project_dir)
template_dir = validate_template_dir(None, None, template_dir)
is_new_project = not is_platformio_project(project_dir)
if is_new_project:
if not silent:
print_header(project_dir)
init_base_project(project_dir)
init_base_project(project_dir, template_dir=template_dir)

with fs.cd(project_dir):
if environment:
Expand Down Expand Up @@ -153,7 +171,7 @@ def print_footer(is_new_project):
)


def init_base_project(project_dir):
def init_base_project(project_dir, template_dir=None):
with fs.cd(project_dir):
config = ProjectConfig()
config.save()
Expand All @@ -169,6 +187,23 @@ def init_base_project(project_dir):
os.makedirs(path)
if cb:
cb(path)
if template_dir:
copy_project_template(template_dir, project_dir)
Comment on lines +190 to +191

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing CLI tests for project_init_cmd (see tests/commands/test_init.py), but this new --template-dir behavior isn’t covered. Please add tests that verify template files are copied into a new project and that existing scaffold files aren’t overwritten (or whatever the intended overwrite policy is).

Copilot uses AI. Check for mistakes.


def copy_project_template(template_dir, project_dir):
for root, dirs, files in os.walk(template_dir):
relpath = os.path.relpath(root, template_dir)
dst_root = project_dir if relpath == "." else os.path.join(project_dir, relpath)
os.makedirs(dst_root, exist_ok=True)
for dname in dirs:
os.makedirs(os.path.join(dst_root, dname), exist_ok=True)
for fname in files:
src_file = os.path.join(root, fname)
dst_file = os.path.join(dst_root, fname)
if os.path.exists(dst_file):
continue
shutil.copy2(src_file, dst_file)

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shutil.copy2() follows symlinks by default, so a template containing symlinked files will copy the target contents into the project (and will not preserve the symlink). That’s surprising behavior for a “template copy”, and it can also end up copying special/unexpected files. Consider explicitly choosing a symlink policy (skip symlinks, preserve them with follow_symlinks=False, and/or validate file types before copying).

Suggested change
shutil.copy2(src_file, dst_file)
shutil.copy2(src_file, dst_file, follow_symlinks=False)

Copilot uses AI. Check for mistakes.


def init_include_readme(include_dir):
Expand Down
Loading