Skip to content

feat/files-commands#39

Open
mateuscardosodeveloper wants to merge 19 commits into
feat/watch-shortcutsfrom
feat/files-commands
Open

feat/files-commands#39
mateuscardosodeveloper wants to merge 19 commits into
feat/watch-shortcutsfrom
feat/files-commands

Conversation

@mateuscardosodeveloper

@mateuscardosodeveloper mateuscardosodeveloper commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What does PR do?

Adds a complete Files management command surface to the TagoIO CLI, plus the generic primitives a project needs to deploy and locally serve custom widgets without touching the web UI.

📁 Files manager

Nine files-* commands over TagoIO Files, all sharing one env/token resolution and a throttled queue (rate-limit aware):

  • files-upload — upload a file or folder, with the correct Content-Type per extension (so index.html renders in an iframe instead of downloading)
  • files-url — print a file URL (public or --signed), region-aware, capturable in scripts
  • files-list — list folders and files under a path (--json)
  • files-move / files-rename — move a file/folder; rename keeps the parent directory
  • files-copy — copy a file/folder
  • files-delete — delete a file/folder, with confirmation (--yes to skip)
  • files-download — download a file/folder to disk; private files via signed URL; path-traversal guarded
  • files-permission — make a file/folder public or private

🗂️ Folder semantics

Folder operations list a prefix recursively and act on each file, reusing a shared lib/files-paths helper. The TagoIO API only lists a folder's contents with a trailing slash — handled centrally.

🔒 Safety

  • Destructive and folder-wide operations confirm unless --yes
  • Download refuses any path that resolves outside the destination
  • Credentials resolved once via lib/resolve-resources; --token supported for CI/CD

🧪 Quality

  • Unit tests per command and helper (SDK and prompts mocked)
  • Validated end to end against a real profile (upload, list, move, rename, copy, delete, download of a private folder, permission)

Pull collectFiles and the throttled upload queue out of the backup
restore path into lib/upload-folder.ts, so the upcoming files-upload
command and backup restore share one implementation.

- uploadFiles(): throttled (concurrency 2, 300ms) upload of a
  pre-collected file list; per-file failure is counted, not fatal.
- uploadFolder(): sugar over collectFiles + uploadFiles for the
  whole-folder case, with a remotePath prefix.
- restoreFiles() now consumes the helper, keeping granular selection
  and spinner behavior unchanged.
Upload a local file or folder to TagoIO Files under a remote path
prefix, through the shared upload-folder helper (throttled, rate-limit
aware). Resolves the account via getEnvironmentConfig and honors --token
for CI/CD. remotePath defaults to the basename of localPath; --public
makes every uploaded file public. Generic by design: no special-casing
of index.html or widgets.
Print the URL of a file already in TagoIO Files. The URL is written to
stdout alone so it can be captured in scripts; --signed returns a signed
URL via the SDK (the built URL is passed to getFileURLSigned).

The base URL is resolved from the profile region via a new getApiURL
helper, so it is correct for named regions (us-e1, eu-w1) and for custom
regions (TagoDeploy, self-hosted) instead of assuming a single endpoint.
Set the URL on a custom (iframe) widget. Reads the widget first and
merges the new url into display, so existing parameters, theme, frame
settings, and header buttons are preserved (read-modify-write). Rejects
non-iframe widgets, since only custom widgets carry a URL. Uses the
Resources class for consistency with the files commands.
Wire files-upload, files-url, and widget-edit-url into buildProgram so
they appear in 'tagoio --help' and the generated man page, each under
its own section header with usage examples. Refreshes the man snapshot.
uploadFolder assumed localPath was always a directory and called
collectFiles, which crashed with ENOTDIR on a single file. Detect
file vs directory: a single file becomes one task with an empty
relative path so it lands at the full remotePath. Adds unit tests
for the single-file and missing-path cases.
Widget editing leaves the CLI: pointing a custom widget at a URL is one
narrow slice of widget management, and a single-field command would lock
the CLI into it. The consuming project (template-analysis) now edits the
widget via the SDK instead. The CLI keeps only the generic Files
primitives (files-upload, files-url). Refreshes the man snapshot.
uploadBase64 cannot set a Content-Type, so uploaded files landed in
storage as application/octet-stream and browsers downloaded them instead
of rendering — which broke custom-widget index.html in the dashboard
iframe. Switch to uploadFile (multipart), which accepts a contentType,
derived from the file extension (html/js/css/svg/fonts/etc., defaulting
to octet-stream). Verified against storage: index.html serves as
text/html, JS as text/javascript, CSS as text/css.
lib/files-paths.ts centralizes the logic the folder-capable files-*
commands share: isFolderPath (trailing-slash or no-extension detection),
listFilesRecursive (flatten a prefix's files across nested folders via
files.list), and remapPrefix (rewrite a file's from-prefix to to- for
move/copy of a folder). Pure and unit-tested so move/copy/delete/
download/permission stay thin over one implementation.
List folders and files under a path in TagoIO Files (root if omitted).
--json emits a machine-readable object on stdout; plain output prints
folders (with a trailing slash) then files. Reuses the env/token
resolution shared by the other files-* commands.
Move a file or a folder prefix. A single file is one move call; a folder
is listed recursively (lib/files-paths) and each file moved with its
prefix remapped, throttled through the shared queue. Folder moves of >1
file confirm unless --yes/--silent. Exports executeMove and
resolveResources for reuse by files-rename. Errors on an empty prefix.
Rename a file or folder in place: keep the parent directory, change only
the last segment. Reuses executeMove from files-move (file: one move;
folder: list + remap + move each). Rejects a newName containing '/',
pointing to files-move for cross-directory moves. Folder renames of >1
file confirm unless --yes.
Copy a file or folder prefix. Single file is one copy call; a folder is
listed recursively and each file copied with its prefix remapped,
throttled through the shared queue. No confirmation (copy is
non-destructive). Reuses resolveResources from files-move. Errors on an
empty prefix.
Delete a file or every file under a folder prefix. Always confirms
(folder shows the count) unless --yes/--silent. Folder deletes list
recursively then delete in batches of 50, throttled between batches. No-op
with a clear message when nothing matches; never reports a partial delete
as success.
Download a file or folder prefix to the local disk. Resolves each file's
URL from the profile region (getApiURL), signing it via getFileURLSigned
when the file is private (checkPermission), then fetches and writes it,
creating parent dirs. Folder downloads list recursively and preserve the
relative structure under the destination (default: the basename in cwd).
Errors on an empty prefix or a failed fetch.
Make a file or folder prefix public or private via changePermission.
Validates the visibility argument (public/private). Single file is one
call; a folder lists recursively and changes each file, throttled.
Folder changes of >1 file confirm unless --yes/--silent. Errors on an
empty prefix.
Wire files-list, files-move, files-rename, files-copy, files-delete,
files-download, and files-permission into the files namespace with
--help examples (move/rename cover file and folder). Refresh the man
snapshot. Completes the Files management surface alongside the Phase 1
files-upload/files-url.
End-to-end testing against a real profile revealed the TagoIO Files API
only returns a folder's contents when the path ends with a slash;
without it the folder comes back as a sibling with no contents. This
broke files-list (showed the folder instead of its files) and made
listFilesRecursive loop/return empty — hanging folder move/copy/delete/
permission/download. Two fixes:
- listFilesRecursive normalizes the prefix with a trailing slash and
  joins the relative folder names from the API before recursing.
- files-list appends a slash to a non-empty path.
Unit tests updated to the real API contract (relative folder names,
slash-terminated listing).
Review follow-ups:
- I2: extract resolveResources from move.ts into lib/resolve-resources.ts
  (with its own tests) and have all files-* commands import it from there,
  removing the odd coupling where copy/delete/permission depended on the
  move module. It now also returns the region for URL-building callers.
- I1: files-list and files-download reuse resolveResources instead of
  reimplementing the env/token resolution.
- C1: files-download guards each folder write with safeJoin, refusing any
  path that resolves outside the destination (path-traversal protection).

Re-validated end to end against a real profile: list, private-folder
download (signed URL), and permission still work.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant