Skip to content
Merged
Show file tree
Hide file tree
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
18 changes: 11 additions & 7 deletions .github/workflows/backfill-release-assets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,20 @@ jobs:
timeout-minutes: 5
permissions:
contents: read
outputs:
tag_name: ${{ steps.validate.outputs.tag_name }}

steps:
- name: Validate tag name
id: validate
env:
TAG_NAME: ${{ inputs.tag_name }}
run: |
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid release tag: $TAG_NAME" >&2
echo "Invalid release tag." >&2
exit 1
fi
printf 'tag_name=%s\n' "$TAG_NAME" >> "$GITHUB_OUTPUT"

build-unix-binaries:
name: Build ${{ matrix.os }} release assets
Expand Down Expand Up @@ -58,7 +62,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
ref: ${{ inputs.tag_name }}
ref: ${{ needs.validate-release-tag.outputs.tag_name }}

- name: Set up Vite+
uses: voidzero-dev/setup-vp@45e5c098f1095cc6b65fd92534603e7be70386c1 # v1
Expand All @@ -78,7 +82,7 @@ jobs:
- name: Package release assets
shell: pwsh
env:
TAG_NAME: ${{ inputs.tag_name }}
TAG_NAME: ${{ needs.validate-release-tag.outputs.tag_name }}
run: |
$version = $env:TAG_NAME.TrimStart("v")
$assetBase = "putio-cli-$version-${{ matrix.asset_os }}-${{ matrix.asset_arch }}"
Expand All @@ -103,7 +107,7 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with:
token: ${{ steps.release-bot.outputs.token }}
tag_name: ${{ inputs.tag_name }}
tag_name: ${{ needs.validate-release-tag.outputs.tag_name }}
files: |
.artifacts/release/*

Expand All @@ -129,7 +133,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
ref: ${{ inputs.tag_name }}
ref: ${{ needs.validate-release-tag.outputs.tag_name }}

- name: Set up Vite+
uses: voidzero-dev/setup-vp@45e5c098f1095cc6b65fd92534603e7be70386c1 # v1
Expand All @@ -149,7 +153,7 @@ jobs:
- name: Package release assets
shell: pwsh
env:
TAG_NAME: ${{ inputs.tag_name }}
TAG_NAME: ${{ needs.validate-release-tag.outputs.tag_name }}
run: |
$version = $env:TAG_NAME.TrimStart("v")
$assetBase = "putio-cli-$version-windows-amd64"
Expand All @@ -169,6 +173,6 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with:
token: ${{ steps.release-bot.outputs.token }}
tag_name: ${{ inputs.tag_name }}
tag_name: ${{ needs.validate-release-tag.outputs.tag_name }}
files: |
.artifacts/release/*
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Focused:
Runtime proofs:

- `./dist/bin.mjs describe`
- `./dist/bin.mjs whoami --output json`
- `./dist/bin.mjs whoami --fields auth --output json`

## Development Guidance

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,10 @@ Link your account:
putio auth login
```

Check the account:
Check the auth source:

```bash
putio whoami --output json
putio whoami --fields auth --output json
```

Read a small JSON result:
Expand Down
19 changes: 17 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,22 @@ verify_checksum() {
}

ensure_install_dir() {
mkdir -p "$INSTALL_DIR"
if [ ! -d "$INSTALL_DIR" ]; then
mkdir -p "$INSTALL_DIR"
chmod 0755 "$INSTALL_DIR"
fi

[ -w "$INSTALL_DIR" ] || fail "install directory is not writable: $INSTALL_DIR"

install_dir_target="$(cd "$INSTALL_DIR" && pwd -P)" || fail "unable to resolve install directory: $INSTALL_DIR"
permissions="$(ls -ld "$install_dir_target" | awk '{print $1}')"
group_write="$(printf '%s' "$permissions" | cut -c6)"
other_write="$(printf '%s' "$permissions" | cut -c9)"

if { [ "$group_write" = "w" ] || [ "$other_write" = "w" ]; } &&
[ "${PUTIO_CLI_ALLOW_SHARED_INSTALL_DIR:-}" != "1" ]; then
fail "install directory is group/world-writable: $INSTALL_DIR. Set PUTIO_CLI_ALLOW_SHARED_INSTALL_DIR=1 to allow this intentionally."
fi
Comment on lines +116 to +131
}

print_path_hint() {
Expand Down Expand Up @@ -156,8 +170,9 @@ printf '%s\n' "putio installer: verifying checksum"
verify_checksum "$checksum_path" "$archive_path"

tar -xzf "$archive_path" -C "$work_dir"
chmod +x "$work_dir/putio"
chmod 0755 "$work_dir/putio"
mv "$work_dir/putio" "$INSTALL_DIR/putio"
chmod 0755 "$INSTALL_DIR/putio"

printf '%s\n' "putio installer: installed to $INSTALL_DIR/putio"
print_path_hint
Expand Down
4 changes: 2 additions & 2 deletions skills/putio-cli/references/reads.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
Prefer structured output:

```bash
putio whoami
putio whoami --fields auth --output json
putio files list --output json
```

Use `--fields` with top-level keys only:

```bash
putio whoami --fields auth,info
putio whoami --fields auth --output json
putio files list --fields files,total --output json
```

Expand Down
52 changes: 52 additions & 0 deletions src/command-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,58 @@ describe("cli command paths", () => {
);
});

it("rejects repeated cursors while streaming file list pages", async () => {
mocks.listFilesMock.mockReturnValueOnce(
Effect.succeed({
cursor: "cursor-1",
files: [{ id: 1, name: "Movies" }],
total: 2,
}),
);
mocks.continueFilesMock.mockReturnValueOnce(
Effect.succeed({
cursor: "cursor-1",
files: [{ id: 2, name: "Shows" }],
total: 2,
}),
);

await expect(
runCliInTest(["putio", "files", "list", "--page-all", "--output", "ndjson"]),
).rejects.toMatchObject({
message: "`files list` pagination returned a repeated cursor.",
});

expect(mocks.continueFilesMock).toHaveBeenCalledTimes(1);
expect(mocks.writeOutputMock).toHaveBeenCalledTimes(1);
});

it("rejects cumulative item overflow while streaming file list pages", async () => {
mocks.listFilesMock.mockReturnValueOnce(
Effect.succeed({
cursor: "cursor-1",
files: Array.from({ length: 60_000 }, (_value, id) => ({ id })),
total: 110_001,
}),
);
mocks.continueFilesMock.mockReturnValueOnce(
Effect.succeed({
cursor: null,
files: Array.from({ length: 50_001 }, (_value, id) => ({ id: id + 60_000 })),
total: 110_001,
}),
);

await expect(
runCliInTest(["putio", "files", "list", "--page-all", "--output", "ndjson"]),
).rejects.toMatchObject({
message: "`files list` pagination exceeded 100000 items.",
});

expect(mocks.continueFilesMock).toHaveBeenCalledTimes(1);
expect(mocks.writeOutputMock).toHaveBeenCalledTimes(1);
});

it("executes files rename", async () => {
await expect(
runCliInTest([
Expand Down
65 changes: 65 additions & 0 deletions src/internal/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,43 @@ describe("resolveReadOutputControls", () => {
expect(String(exit.cause)).toContain("cannot include `?` or `#` fragments");
}
});

it("does not reflect control-bearing field selectors in errors", async () => {
const payload = "\u001B]52;c;cHduZWQ=\u0007";
const exit = await Effect.runPromiseExit(
provideRuntime(
resolveReadOutputControls({
fields: Option.some(payload),
output: "json",
}),
),
);

expect(exit._tag).toBe("Failure");
if (exit._tag === "Failure") {
const cause = String(exit.cause);
expect(cause).toContain("`--fields` selector #1 cannot contain control characters");
expect(cause).not.toContain(payload);
}
});

it("identifies the invalid field selector by position without echoing it", async () => {
const exit = await Effect.runPromiseExit(
provideRuntime(
resolveReadOutputControls({
fields: Option.some("auth,bad.field"),
output: "json",
}),
),
);

expect(exit._tag).toBe("Failure");
if (exit._tag === "Failure") {
const cause = String(exit.cause);
expect(cause).toContain("`--fields` selector #2 only accepts top-level field names");
expect(cause).not.toContain("bad.field");
}
});
});

describe("selectTopLevelFields", () => {
Expand Down Expand Up @@ -272,6 +309,34 @@ describe("collectAllCursorPages", () => {
total: 3,
});
});

it("rejects repeated cursors", async () => {
const continueWithCursor = () =>
Effect.succeed({
cursor: "cursor-1",
files: [{ id: 2 }],
total: 2,
});

const exit = await Effect.runPromiseExit(
collectAllCursorPages({
command: "files list",
continueWithCursor,
initial: {
cursor: "cursor-1",
files: [{ id: 1 }],
total: 2,
},
itemKey: "files",
pageAll: true,
}),
);

expect(exit._tag).toBe("Failure");
if (exit._tag === "Failure") {
expect(String(exit.cause)).toContain("pagination returned a repeated cursor");
}
});
});

describe("agent-safe string validation", () => {
Expand Down
Loading