Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
32b17d4
Add note, date created, and show subcommand
Trenly Apr 29, 2026
697ca1d
Add tests
Trenly Apr 29, 2026
b6502ef
Add pinning to COM APIs
Trenly Apr 29, 2026
ae483b0
Add IsPinned propery for Get-WingetPackage
Trenly Apr 29, 2026
fac4ec4
Add pinning to PowerShell Cmdlets
Trenly Apr 29, 2026
7ce3b27
Spelling
Trenly Apr 29, 2026
35322e7
Inherit from 1.0 Interface
Trenly Apr 29, 2026
236e667
Contract 29 is not released yet
Trenly Apr 29, 2026
1c89957
Adddress comments about resource strings
Trenly May 1, 2026
fa617a6
Update comment to indicate the appropriate contract version
Trenly May 1, 2026
7f33f56
Make methods public
Trenly May 1, 2026
3c7d4a6
Adjust how new schema is created
Trenly May 1, 2026
99a8760
Reduce duplicated code through inheritance
Trenly May 1, 2026
e336194
Use statement builder, savepoints, and tuples for ownership
Trenly May 1, 2026
0cb653b
Use a time point instead of strings
Trenly May 1, 2026
29d6203
Guard against non-existent source
Trenly May 1, 2026
184cc7a
Cache IsPinned
Trenly May 1, 2026
952e56e
Merge default pin add behavior into single test
Trenly May 1, 2026
e823b75
Ensure table is not created in a partial state
Trenly May 1, 2026
195846f
Add an alternate function for optional parms
Trenly May 1, 2026
0468b34
Move to PackageCatalogReference
Trenly May 1, 2026
4bfde99
Move functionality to converters
Trenly May 1, 2026
243798b
Remove version comment
Trenly May 1, 2026
4b7b8ef
Update filters so 1.1 schema is visible
Trenly May 1, 2026
e93e3d1
Create the correct interface and migrate only if newer
Trenly May 1, 2026
ae87ff1
Use correct version info
Trenly May 1, 2026
917db77
Move invariants outside try catch
Trenly May 1, 2026
34a727c
Add TryRemovePin for simplicity
Trenly May 1, 2026
01aad8e
Refactor to use -Blocking and -GatedVersion
Trenly May 1, 2026
ad9932f
Spelling
Trenly May 1, 2026
a96e43e
Add Pester Tests
Trenly May 1, 2026
fde7382
Tests should now be part of default creation test
Trenly May 1, 2026
d5f682c
Further abstract the interface
Trenly May 1, 2026
8f819c1
Update test name
Trenly May 1, 2026
91f532a
Add comment
Trenly May 1, 2026
595abca
Appease our AI overlords with an update to their tomes
Trenly May 1, 2026
7e83a83
Unify on AssignValue to avoid future confusion
Trenly May 1, 2026
615ddca
Ensure pin writes always record the last updated time
Trenly May 1, 2026
1f8932d
Use original symbol for creation
Trenly May 1, 2026
df55096
Add missing System namepace
Trenly May 1, 2026
6d085dd
Restore conditional setting of date
Trenly May 1, 2026
ea9f0d8
Ensure ShouldProcess is always respected
Trenly May 1, 2026
d207c77
Fix minor issue in copilot instructions
Trenly May 1, 2026
349a0a1
Validate provided pin types
Trenly May 1, 2026
840e5ae
Cache all pins and only fall back to get if set is empty
Trenly May 1, 2026
65f8373
Change file name to remove override
Trenly May 1, 2026
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
3 changes: 3 additions & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ appdata
appinstallertest
applic
appname
appone
appshutdown
APPTERMINATION
archs
Expand Down Expand Up @@ -339,6 +340,7 @@ megamorf
microsoftentraid
microsoftentraidforazureblobstorage
midl
migratepintable
minidump
MINORVERSION
missingdependency
Expand Down Expand Up @@ -431,6 +433,7 @@ Pherson
pid
pidl
pidlist
pintable
PKCS
pkgmgr
pkindex
Expand Down
99 changes: 99 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,105 @@ void WorkflowTask(Execution::Context& context)
- Parsing in `AppInstallerCommonCore/Manifest/`
- Multi-file manifests: installer, locale, version, defaultLocale

## SQLite Statement Builder
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should go into a path specific instructions file (with the appropriate target paths). It is a lot of lines that are only needed in a few cases.


**Always use `AppInstaller::SQLite::Builder::StatementBuilder` when writing SQLite database code. Never write raw SQL strings.**

The builder is in `<winget/SQLiteStatementBuilder.h>` (namespace `AppInstaller::SQLite::Builder`). It generates type-safe, parameterized SQL and ensures symbolic names are used for tables and columns throughout.

### Key conventions

- Define table and column names as `constexpr std::string_view` constants, then pass them to the builder.
- Use `ColumnBuilder` with chained modifiers (`.NotNull()`, `.Unique()`, `.Default(value)`) when creating tables.
- Use `IntegerPrimaryKey()` for auto-increment rowid primary keys.
- Use `Unbound` as a placeholder and bind values later via `Statement::Bind()`, or pass values directly to have them bound automatically.

### Common operations

```cpp
using namespace AppInstaller::SQLite::Builder;

// Symbolic names
static constexpr std::string_view s_MyTable = "my_table"sv;
static constexpr std::string_view s_Col_Id = "id"sv;
static constexpr std::string_view s_Col_Name = "name"sv;
static constexpr std::string_view s_Col_Value = "value"sv;

// CREATE TABLE
StatementBuilder builder;
builder.CreateTable(s_MyTable).Columns({
IntegerPrimaryKey(),
ColumnBuilder(s_Col_Name, Type::Text).NotNull().Unique(),
ColumnBuilder(s_Col_Value, Type::Int64).NotNull().Default(0),
});
builder.Execute(connection);

// SELECT
StatementBuilder builder;
builder.Select({ s_Col_Id, s_Col_Name })
.From(s_MyTable)
.Where(s_Col_Name).Equals(nameValue);
auto stmt = builder.Prepare(connection);
while (stmt.Step()) { /* stmt.GetColumn<T>(index) */ }

// INSERT
StatementBuilder builder;
builder.InsertInto(s_MyTable)
.Columns({ s_Col_Name, s_Col_Value })
.Values(nameValue, intValue);
builder.Execute(connection);

// UPDATE
StatementBuilder builder;
builder.Update(s_MyTable).Set()
.Column(s_Col_Value).AssignValue(newValue)
.Where(s_Col_Id).Equals(rowId);
builder.Execute(connection);

// DELETE
StatementBuilder builder;
builder.DeleteFrom(s_MyTable)
.Where(s_Col_Id).Equals(rowId);
builder.Execute(connection);

// ALTER TABLE – add a column
StatementBuilder builder;
builder.AlterTable(s_MyTable).Add(s_Col_NewCol, Type::Text).NotNull().Default(0);
builder.Execute(connection);
```

### Nullable values: `Equals()` vs `AssignValue()`

Both accept `std::optional<T>`, but they behave differently when the optional is empty and must be used in the right context:

| Method | Empty optional emits | Use in |
|---|---|---|
| `Equals(optional<T>)` | `IS NULL` | **WHERE** / filter clauses |
| `AssignValue(optional<T>)` | `= ?` (binds NULL) | **UPDATE SET** assignments |

Using `Equals(optional)` in an UPDATE SET clause is a bug — SQLite does not accept `col = IS NULL`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
Using `Equals(optional)` in an UPDATE SET clause is a bug — SQLite does not accept `col = IS NULL`.
Using `Equals(optional)` in an UPDATE SET clause is a bug — SQLite does not accept `col IS NULL`.


```cpp
// ✅ Correct: Equals for WHERE filter, AssignValue for SET assignment
std::optional<int64_t> maybeEpoch = ...;
std::optional<std::string> maybeNote = ...;

builder.Update(s_MyTable).Set()
.Column(s_Col_Name).AssignValue(requiredName) // non-optional: AssignValue in SET
.Column(s_Col_Epoch).AssignValue(maybeEpoch) // nullable: must use AssignValue in SET
.Column(s_Col_Note).AssignValue(maybeNote) // nullable: must use AssignValue in SET
.Where(s_Col_Id).Equals(rowId); // filter: Equals is correct here

// ✅ Correct: Equals(optional) in WHERE — emits "IS NULL" when empty
builder.Select(s_Col_Id).From(s_MyTable)
.Where(s_Col_Note).Equals(maybeNote); // → "WHERE note IS NULL" when empty
```

### Execution

- `builder.Execute(connection)` — prepares, binds, and runs a statement that returns no rows.
- `builder.Prepare(connection)` — returns a `SQLite::Statement`; call `.Step()` to iterate, `.GetColumn<T>(index)` to read values.

## Naming Conventions

- **Namespace structure**: `AppInstaller::<Area>[::<Subarea>]`
Expand Down
2 changes: 2 additions & 0 deletions src/AppInstallerCLICore/Argument.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ namespace AppInstaller::CLI
return { type, "blocking"_liv, ArgTypeCategory::None, ArgTypeExclusiveSet::PinType };
case Execution::Args::Type::PinInstalled:
return { type, "installed"_liv, ArgTypeCategory::None };
case Execution::Args::Type::PinNote:
return { type, "note"_liv, ArgTypeCategory::None };

// Error command
case Execution::Args::Type::ErrorInput:
Expand Down
34 changes: 34 additions & 0 deletions src/AppInstallerCLICore/Commands/PinCommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ namespace AppInstaller::CLI
std::make_unique<PinRemoveCommand>(FullName()),
std::make_unique<PinListCommand>(FullName()),
std::make_unique<PinResetCommand>(FullName()),
std::make_unique<PinShowCommand>(FullName()),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would prefer that we follow the model of package list and have --details on the existing list command. That also removes the need to create a whole new search mechanism and just makes the change in ReportPins.

Followup: Well it looks like it would need a whole new search mechanism to actually implement the arguments that it claims to support...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, we would need a whole new search mechanism anyways. I do think that there is some overlap with winget list <query> --details, but to me it felt more akin to winget show <query>. The semantics of list vs show as a base command don't really apply to pins. Where show in the base command is for a remote package / manifest information, list is for installed applications.

I will think on this and probably end up refactoring to extend the behavior of list instead of adding show

});
}

Expand Down Expand Up @@ -65,6 +66,7 @@ namespace AppInstaller::CLI
Argument::ForType(Args::Type::Force),
Argument{ Args::Type::BlockingPin, Resource::String::PinAddBlockingArgumentDescription, ArgumentType::Flag },
Argument{ Args::Type::PinInstalled, Resource::String::PinInstalledArgumentDescription, ArgumentType::Flag },
Argument{ Args::Type::PinNote, Resource::String::PinNoteArgumentDescription, ArgumentType::Standard },
};
}

Expand Down Expand Up @@ -343,4 +345,36 @@ namespace AppInstaller::CLI
Workflow::ReportPins;
}
}

std::vector<Argument> PinShowCommand::GetArguments() const
{
return {
Argument::ForType(Args::Type::Query),
Argument::ForType(Args::Type::Id),
Argument::ForType(Args::Type::Name),
Argument::ForType(Args::Type::Exact),
};
}

Resource::LocString PinShowCommand::ShortDescription() const
{
return { Resource::String::PinShowCommandShortDescription };
}

Resource::LocString PinShowCommand::LongDescription() const
{
return { Resource::String::PinShowCommandLongDescription };
}

Utility::LocIndView PinShowCommand::HelpLink() const
{
return s_PinCommand_HelpLink;
}

void PinShowCommand::ExecuteInternal(Execution::Context& context) const
{
context <<
Workflow::OpenPinningIndex(/* readOnly */ true) <<
Workflow::ShowPinDetails;
}
}
15 changes: 15 additions & 0 deletions src/AppInstallerCLICore/Commands/PinCommand.h
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,19 @@ namespace AppInstaller::CLI
protected:
void ExecuteInternal(Execution::Context& context) const override;
};

struct PinShowCommand final : public Command
{
PinShowCommand(std::string_view parent) : Command("show", parent) {}

std::vector<Argument> GetArguments() const override;

Resource::LocString ShortDescription() const override;
Resource::LocString LongDescription() const override;

Utility::LocIndView HelpLink() const override;

protected:
void ExecuteInternal(Execution::Context& context) const override;
};
}
1 change: 1 addition & 0 deletions src/AppInstallerCLICore/ExecutionArgs.h
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ namespace AppInstaller::CLI::Execution
GatedVersion, // Differs from Version in that this supports wildcards
BlockingPin,
PinInstalled,
PinNote, // User-provided note to attach to a pin

// Error command
ErrorInput,
Expand Down
12 changes: 12 additions & 0 deletions src/AppInstallerCLICore/Resources.h
Original file line number Diff line number Diff line change
Expand Up @@ -530,13 +530,16 @@ namespace AppInstaller::CLI::Resource
WINGET_DEFINE_RESOURCE_STRINGID(PinCannotOpenIndex);
WINGET_DEFINE_RESOURCE_STRINGID(PinCommandLongDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinCommandShortDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinDateAdded);
WINGET_DEFINE_RESOURCE_STRINGID(PinDoesNotExist);
WINGET_DEFINE_RESOURCE_STRINGID(PinExistsOverwriting);
WINGET_DEFINE_RESOURCE_STRINGID(PinExistsUseForceArg);
WINGET_DEFINE_RESOURCE_STRINGID(PinInstalledArgumentDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinInstalledSource);
WINGET_DEFINE_RESOURCE_STRINGID(PinListCommandLongDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinListCommandShortDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinNote);
WINGET_DEFINE_RESOURCE_STRINGID(PinNoteArgumentDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinNoPinsExist);
WINGET_DEFINE_RESOURCE_STRINGID(PinRemoveCommandLongDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinRemoveCommandShortDescription);
Expand All @@ -546,6 +549,15 @@ namespace AppInstaller::CLI::Resource
WINGET_DEFINE_RESOURCE_STRINGID(PinResetSuccessful);
WINGET_DEFINE_RESOURCE_STRINGID(PinResettingAll);
WINGET_DEFINE_RESOURCE_STRINGID(PinResetUseForceArg);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowCommandLongDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowCommandShortDescription);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowLabelDateAdded);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowLabelId);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowLabelNote);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowLabelSource);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowLabelType);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowLabelVersion);
WINGET_DEFINE_RESOURCE_STRINGID(PinShowNoMatchFound);
WINGET_DEFINE_RESOURCE_STRINGID(PinType);
WINGET_DEFINE_RESOURCE_STRINGID(PinVersion);
WINGET_DEFINE_RESOURCE_STRINGID(PlatformArgumentDescription);
Expand Down
115 changes: 114 additions & 1 deletion src/AppInstallerCLICore/Workflows/PinFlow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
#include "pch.h"
#include "Resources.h"
#include "PinFlow.h"
#include "ShowFlow.h"
#include "TableOutput.h"
#include <AppInstallerDateTime.h>
#include <winget/PinningData.h>
#include <winget/RepositorySearch.h>
#include <winget/PackageVersionSelection.h>
Expand Down Expand Up @@ -197,9 +199,18 @@ namespace AppInstaller::CLI::Workflow

if (!pinsToAddOrUpdate.empty())
{
for (const auto& pin : pinsToAddOrUpdate)
auto pinTime = std::chrono::system_clock::now();

std::optional<std::string> note;
if (context.Args.Contains(Execution::Args::Type::PinNote))
{
note = std::string{ context.Args.GetArg(Execution::Args::Type::PinNote) };
}

for (auto& pin : pinsToAddOrUpdate)
{
pin.SetDateAdded(pinTime);
pin.SetNote(note);
pinningData.AddOrUpdatePin(pin);
}

Expand Down Expand Up @@ -335,4 +346,106 @@ namespace AppInstaller::CLI::Workflow
context.Reporter.Info() << Resource::String::PinNoPinsExist << std::endl;
}
}

void ShowPinDetails(Execution::Context& context)
{
auto& pinningData = context.Get<Execution::Data::PinningData>();
auto allPins = pinningData.GetAllPins();

// Apply filtering based on provided arguments
bool hasId = context.Args.Contains(Execution::Args::Type::Id);
bool hasName = context.Args.Contains(Execution::Args::Type::Name);
bool hasQuery = context.Args.Contains(Execution::Args::Type::Query);
bool exactMatch = context.Args.Contains(Execution::Args::Type::Exact);

Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

winget pin show currently allows running with no query/id/name arguments, in which case it will match every pin and dump verbose details for all of them. This seems inconsistent with the intent of a "show" command (and the comment above suggests at least one filter is expected). Consider enforcing that at least one of --id/--name/--query is provided and terminating with an appropriate error message when none are specified.

Suggested change
if (!hasId && !hasName && !hasQuery)
{
context.Reporter.Error() << "The 'pin show' command requires at least one of --id, --name, or --query." << std::endl;
AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_INVALID_CL_ARGUMENTS);
}

Copilot uses AI. Check for mistakes.
std::vector<Pinning::Pin> matchingPins;
for (const auto& pin : allPins)
{
const auto& packageId = pin.GetKey().PackageId;

if (hasId)
{
std::string_view idArg = context.Args.GetArg(Execution::Args::Type::Id);
bool match = exactMatch
? Utility::CaseInsensitiveEquals(packageId, idArg)
: Utility::CaseInsensitiveContainsSubstring(packageId, idArg);
if (!match)
{
continue;
}
}
else if (hasName || hasQuery)
{
// Without an open source, we can only match against PackageId
std::string_view queryArg = hasName
? context.Args.GetArg(Execution::Args::Type::Name)
: context.Args.GetArg(Execution::Args::Type::Query);
bool match = exactMatch
? Utility::CaseInsensitiveEquals(packageId, queryArg)
: Utility::CaseInsensitiveContainsSubstring(packageId, queryArg);
if (!match)
{
continue;
}
}

matchingPins.push_back(pin);
}

if (matchingPins.empty())
{
context.Reporter.Info() << Resource::String::PinShowNoMatchFound << std::endl;
AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_PIN_DOES_NOT_EXIST);
}

auto info = context.Reporter.Info();
bool firstPin = true;
for (const auto& pin : matchingPins)
{
if (!firstPin)
{
info << std::endl;
}
firstPin = false;

const auto& pinKey = pin.GetKey();

// ID
ShowSingleLineField(info, Resource::String::PinShowLabelId, Utility::LocIndView{ pinKey.PackageId });

// Source
if (!pinKey.SourceId.empty() && !pinKey.IsForInstalled())
{
ShowSingleLineField(info, Resource::String::PinShowLabelSource, Utility::LocIndView{ pinKey.SourceId });
}

// Type
std::string pinTypeStr{ ToString(pin.GetType()) };
ShowSingleLineField(info, Resource::String::PinShowLabelType, Utility::LocIndView{ pinTypeStr });

// Version (gated version string; empty for pinning/blocking pins)
std::string gatedVersionStr = pin.GetGatedVersion().ToString();
if (!gatedVersionStr.empty())
{
ShowSingleLineField(info, Resource::String::PinShowLabelVersion, Utility::LocIndView{ gatedVersionStr });
}

// Date Added
const auto& dateAdded = pin.GetDateAdded();
if (dateAdded.has_value())
{
std::string dateAddedStr = Utility::TimePointToString(*dateAdded,
Utility::TimeFacet::Year | Utility::TimeFacet::Month | Utility::TimeFacet::Day |
Utility::TimeFacet::Hour | Utility::TimeFacet::Minute | Utility::TimeFacet::Second);
ShowSingleLineField(info, Resource::String::PinShowLabelDateAdded, Utility::LocIndView{ dateAddedStr });
}

// Note (only shown if present)
const auto& note = pin.GetNote();
if (note.has_value() && !note->empty())
{
ShowSingleLineField(info, Resource::String::PinShowLabelNote, Utility::LocIndView{ *note });
}
}
}
}
Loading