Compare commits

..

No commits in common. "bfd702e47090f657fcf61713e63d5766dc0d3e57" and "979f5bd7677b8cf6ce1a8639752b4b7490ce218b" have entirely different histories.

55 changed files with 100 additions and 2423 deletions

View file

@ -1,27 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: dotnet test NozCli.Tests/NozCli.Tests.csproj --no-build -c Release --verbosity normal

View file

@ -1,81 +1,26 @@
# Changelog # Changelog
All notable changes to noz-cli are documented here. All notable changes to noz-cli are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [Semantic Versioning](https://semver.org/). Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — versioning follows [Semantic Versioning](https://semver.org/).
--- ---
## [Unreleased] ## [Unreleased]
## [0.2.0] — 2026-05-17
### Added
**REPL commands**
- `mv <old> <new>` — rename a note and atomically rewrite all inbound wikilinks and markdown links
- `mkdir <name>` — create a folder by writing a `_index.md`; navigates into the new folder immediately
- `link <source> <target>` — add a wikilink to the "Siehe auch" section (or create it); `--both` for bidirectional
- `search <query>` — full-text search via Meilisearch; context-aware (defaults to current topic); `--topic`, `--limit` flags
- `templates` — list available server-side note templates
- `new --template <name> <slug>` — create a note from a template with `{{title}}`, `{{date}}`, `{{topic}}` placeholders
- `cat --raw <slug>` — print raw markdown (default now renders via MarkdownRenderer)
- `clear` — clear the terminal screen
**PowerShell module**
- `Get-NozGraph` — all wikilink edges as `NozGraphEdge` objects; filterable by `-Source` / `-Target`
- `Find-NozNote` — Meilisearch full-text search; `-Topic`, `-Limit`
- `Measure-NozGarden` — garden-wide statistics: note counts by status/topic/lang, link count, orphan count
- `Set-NozNoteStatus` — patch the `status:` frontmatter field in place; `SupportsShouldProcess`
- `Get-NozOrphans` — notes with no incoming wikilinks; `-Topic` filter
- `Rename-NozNote` — wraps `NoteRenamer` for pipeline use; `-PassThru`, `-WhatIf`
- `Get-NozTemplate` — list server-side templates
- `Add-NozLink` — add wikilinks between notes; `-Both`, `-Section`
**Server API routes** (noz Next.js app)
- `GET /api/cli/graph` — full wikilink adjacency list from pre-built index
- `GET /api/cli/search` — Meilisearch / local-search with `?q`, `?topic`, `?limit`
- `PUT /api/cli/folders/[...path]` — create `_index.md` for a folder
- `GET /api/cli/templates` — list templates from `_templates/`
- `GET /api/cli/templates/[name]` — fetch a single template body
- `PUT /api/cli/templates/[name]` — create or update a template
**Core library**
- `NoteRenamer` — fetches inbound edges, creates new note, rewrites links, deletes old slug
- `NoteLinkHelper``AddLink()` inserts wikilinks into existing sections or appends new ones; `ApplyPlaceholders()` for template rendering
- `NozApiClient.GetGraphAsync()`, `SearchAsync()`, `GetTemplatesAsync()`, `GetTemplateAsync()`, `PutFolderIndexAsync()`
**Templates**
- Four sample templates in `content/noosphere/_templates/`: `rezept`, `buch-notiz`, `konzept`, `meeting`
### Fixed
- `walkNotes` now skips directories with `_` prefix (previously only filenames were checked — `_templates/` content was indexed as regular notes)
- Notes listing API now includes the `date` field
---
## [0.1.0] — 2026-05-16 ## [0.1.0] — 2026-05-16
### Added ### Added
- Interactive REPL with topic-based navigation (`cd`, `ls`, `ls -la`, `tree`) - Interactive REPL with topic-based navigation (`cd`, `ls`, `ls -la`, `tree`)
- `cat` — render a note with MarkdownRenderer (headings, blockquotes, code blocks, inline formatting) - `cat` — print raw markdown of a note
- `edit` — open note in terminal editor (nano/vim) or VS Code / Zed / Sublime (`-c`) - `edit` — open note in terminal editor (nano/vim) or VS Code (`-c` flag)
- `new` — create a note with frontmatter template, open in editor - `new` — create a new note from a template
- `rm` — delete a note from server - `rm` — delete a note
- `sync` — pull notes from server to local directory (parallel downloads, SHA-256 hash diffing, `--all`, `--dry-run`) - `sync` — pull notes from server to local directory (parallel, hash-diffed)
- `push` — push local `.md` files to server with slug validation - `push` — push local markdown files to server
- `status` — diff between a local folder and the server - `search` — full-text search via Meilisearch
- `config` — view and set `editor` / `code_editor` preferences - `config` — view and set editor preferences
- `version` — print version and git hash - `version` — show version and git hash
- `help` — list all commands with usage - JSON note cache (`~/.cache/noz/notes.json`) for instant startup + background refresh
- `exit` / `quit` / `q` — quit the REPL - Cache timestamp displayed in startup stats line
- JSON note cache (`~/.cache/noz/notes.json`) — instant startup, background refresh, `chmod 600`, atomic write
- Cache age shown in startup stats line ("vor 5 Min.")
- Ctrl+C: idle → exit REPL; during command → cancel command only
- Native AOT binary for macOS arm64 (~7.6 MB, no runtime required) - Native AOT binary for macOS arm64 (~7.6 MB, no runtime required)
- Tab completion for commands, slugs, and folder segments - Tab completion for commands, slugs and folder segments
- `SlugGuard` — path traversal prevention for all slug inputs and file operations
- `MarkdownRenderer` — segment-based inline escaping (prevents Spectre markup injection from note content)
- PowerShell module `Noz` with `Get-NozNote`, `Get-NozNoteContent`, `Set-NozNote`, `New-NozNote`, `Remove-NozNote`
- xUnit test suite (52 tests): `SlugGuardTests`, `ReplContextTests`, `NotesCacheTests`, `NoteRenamerTests`, `NoteLinkHelperTests`
- Forgejo Actions CI pipeline (`.forgejo/workflows/ci.yml`): build + test on every push/PR
- Version string from `AssemblyInformationalVersion` with embedded git short hash

View file

@ -1,43 +0,0 @@
using System.Management.Automation;
using NozCli.Core.Client;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Add, "NozLink", SupportsShouldProcess = true)]
public sealed class AddNozLinkCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string Source { get; set; } = "";
[Parameter(Mandatory = true, Position = 1)]
public string Target { get; set; } = "";
[Parameter]
public SwitchParameter Both { get; set; }
[Parameter]
public string Section { get; set; } = "Siehe auch";
protected override void ProcessRecord()
{
if (!ShouldProcess(Source, $"Add-NozLink → {Target}")) return;
LinkOne(Source, Target);
if (Both) LinkOne(Target, Source);
}
private void LinkOne(string source, string target)
{
var note = Run(Api.GetNoteAsync(source));
var updated = NoteLinkHelper.AddLink(note.Markdown, target, Section);
if (updated == note.Markdown)
{
WriteVerbose($"{source} → {target}: bereits verlinkt.");
return;
}
Run(Api.PutNoteAsync(source, updated));
WriteVerbose($"{source} → {target}: verlinkt.");
}
}

View file

@ -1,35 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Find, "NozNote")]
[OutputType(typeof(NozSearchHit))]
public sealed class FindNozNoteCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0)]
public string Query { get; set; } = "";
[Parameter]
public string? Topic { get; set; }
[Parameter]
public int Limit { get; set; } = 20;
protected override void ProcessRecord()
{
var hits = Run(Api.SearchAsync(Query, Topic, Limit));
foreach (var h in hits)
{
WriteObject(new NozSearchHit
{
Slug = h.Slug,
Title = h.Title,
Description = h.Description,
Topic = h.Topic,
Status = h.Status,
Lang = h.Lang,
});
}
}
}

View file

@ -1,38 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "NozGraph")]
[OutputType(typeof(NozGraphEdge))]
public sealed class GetNozGraphCmdlet : NozCmdletBase
{
[Parameter]
public string? Source { get; set; }
[Parameter]
public string? Target { get; set; }
protected override void ProcessRecord()
{
var graph = Run(Api.GetGraphAsync());
var nodeMap = graph.Nodes.ToDictionary(n => n.Id, n => n.Title);
var edges = graph.Edges.AsEnumerable();
if (Source is not null)
edges = edges.Where(e => e.Source.Contains(Source, StringComparison.OrdinalIgnoreCase));
if (Target is not null)
edges = edges.Where(e => e.Target.Contains(Target, StringComparison.OrdinalIgnoreCase));
foreach (var e in edges)
{
WriteObject(new NozGraphEdge
{
Source = e.Source,
Target = e.Target,
SourceTitle = nodeMap.GetValueOrDefault(e.Source, e.Source),
TargetTitle = nodeMap.GetValueOrDefault(e.Target, e.Target),
});
}
}
}

View file

@ -1,41 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>List notes from the noz server.</summary>
/// <example>
/// Get-NozNote
/// Get-NozNote -Topic psychologie
/// Get-NozNote -Status seed -Lang de
/// Get-NozNote | Group-Object Topic | Sort-Object Count -Descending
/// Get-NozNote | Export-Csv ~/notes.csv
/// </example>
[Cmdlet(VerbsCommon.Get, "NozNote")]
[OutputType(typeof(NozNote))]
public sealed class GetNozNoteCmdlet : NozCmdletBase
{
[Parameter(Position = 0)]
public string? Topic { get; set; }
[Parameter]
[ValidateSet("seed", "budding", "evergreen")]
public string? Status { get; set; }
[Parameter]
[ValidateSet("de", "en")]
public string? Lang { get; set; }
protected override void ProcessRecord()
{
var notes = Run(Api.GetNotesAsync());
var filtered = notes.AsEnumerable();
if (Topic is not null) filtered = filtered.Where(n => n.Topic == Topic);
if (Status is not null) filtered = filtered.Where(n => n.Status == Status);
if (Lang is not null) filtered = filtered.Where(n => n.Lang == Lang);
foreach (var note in filtered.OrderBy(n => n.Slug))
WriteObject(NozNote.From(note));
}
}

View file

@ -1,32 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>Get the markdown content of a note.</summary>
/// <example>
/// Get-NozNoteContent -Slug psychologie/flow-theorie
/// "psychologie/flow-theorie" | Get-NozNoteContent
/// Get-NozNote -Topic psychologie | Get-NozNoteContent | Select-Object Slug, Markdown
/// </example>
[Cmdlet(VerbsCommon.Get, "NozNoteContent")]
[OutputType(typeof(NozNoteContent))]
public sealed class GetNozNoteContentCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0,
ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
public string Slug { get; set; } = "";
protected override void ProcessRecord()
{
var content = Run(Api.GetNoteAsync(Slug));
WriteObject(new NozNoteContent { Slug = content.Slug, Markdown = content.Markdown });
}
}
public sealed class NozNoteContent
{
public string Slug { get; init; } = "";
public string Markdown { get; init; } = "";
public override string ToString() => Slug;
}

View file

@ -1,31 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// Returns notes that have no incoming wikilinks — good candidates for cleanup or linking.
[Cmdlet(VerbsCommon.Get, "NozOrphans")]
[OutputType(typeof(NozNote))]
public sealed class GetNozOrphansCmdlet : NozCmdletBase
{
[Parameter]
public string? Topic { get; set; }
protected override void ProcessRecord()
{
var notesTask = Api.GetNotesAsync();
var graphTask = Api.GetGraphAsync();
var notes = Run(notesTask);
var graph = Run(graphTask);
var targets = new HashSet<string>(graph.Edges.Select(e => e.Target));
var orphans = notes.Where(n => !targets.Contains(n.Slug));
if (Topic is not null)
orphans = orphans.Where(n => string.Equals(n.Topic, Topic, StringComparison.OrdinalIgnoreCase));
foreach (var n in orphans.OrderBy(n => n.Topic).ThenBy(n => n.Slug))
WriteObject(NozNote.From(n));
}
}

View file

@ -1,23 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "NozTemplate")]
[OutputType(typeof(NozTemplate))]
public sealed class GetNozTemplateCmdlet : NozCmdletBase
{
[Parameter(Position = 0)]
public string? Name { get; set; }
protected override void ProcessRecord()
{
var templates = Run(Api.GetTemplatesAsync());
var filtered = Name is null
? templates
: templates.Where(t => t.Name.Contains(Name, StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var t in filtered.OrderBy(t => t.Name))
WriteObject(new NozTemplate { Name = t.Name, Description = t.Description });
}
}

View file

@ -1,41 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsDiagnostic.Measure, "NozGarden")]
[OutputType(typeof(NozGardenStats))]
public sealed class MeasureNozGardenCmdlet : NozCmdletBase
{
protected override void ProcessRecord()
{
var notesTask = Api.GetNotesAsync();
var graphTask = Api.GetGraphAsync();
var notes = Run(notesTask);
var graph = Run(graphTask);
var targets = new HashSet<string>(graph.Edges.Select(e => e.Target));
var orphans = notes.Count(n => !targets.Contains(n.Slug));
var byTopic = notes
.GroupBy(n => n.Topic ?? "—")
.ToDictionary(g => g.Key, g => g.Count());
var byLang = notes
.GroupBy(n => n.Lang)
.ToDictionary(g => g.Key, g => g.Count());
WriteObject(new NozGardenStats
{
TotalNotes = notes.Count,
SeedCount = notes.Count(n => n.Status == "seed"),
BuddingCount = notes.Count(n => n.Status == "budding"),
EvergreenCount = notes.Count(n => n.Status == "evergreen"),
TopicCount = byTopic.Count,
LinkCount = graph.Edges.Count,
OrphanCount = orphans,
NotesByTopic = byTopic,
NotesByLang = byLang,
});
}
}

View file

@ -1,59 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>Create a new note on the server.</summary>
/// <example>
/// New-NozNote -Slug psychologie/neue-notiz -Title "Neue Notiz"
/// New-NozNote -Slug biosysteme/pilze -Title "Pilze" -Topic biosysteme -Status budding -Lang de
/// </example>
[Cmdlet(VerbsCommon.New, "NozNote", SupportsShouldProcess = true)]
[OutputType(typeof(NozNote))]
public sealed class NewNozNoteCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0)]
public string Slug { get; set; } = "";
[Parameter(Mandatory = true, Position = 1)]
public string Title { get; set; } = "";
[Parameter]
public string? Topic { get; set; }
[Parameter]
[ValidateSet("seed", "budding", "evergreen")]
public string Status { get; set; } = "seed";
[Parameter]
[ValidateSet("de", "en")]
public string Lang { get; set; } = "de";
[Parameter]
public string Description { get; set; } = "";
protected override void ProcessRecord()
{
if (!ShouldProcess(Slug, "Create note")) return;
var topic = Topic ?? (Slug.Contains('/') ? Slug.Split('/')[0] : Slug);
var markdown = $"""
---
title: "{Title}"
description: "{Description}"
topic: {topic}
status: {Status}
lang: {Lang}
---
# {Title}
""";
Run(Api.PutNoteAsync(Slug, markdown));
var notes = Run(Api.GetNotesAsync());
var created = notes.FirstOrDefault(n => n.Slug == Slug);
if (created is not null)
WriteObject(NozNote.From(created));
}
}

View file

@ -1,24 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>Delete a note from the server.</summary>
/// <example>
/// Remove-NozNote -Slug psychologie/alte-notiz
/// Get-NozNote -Status seed | Where-Object Topic -eq "draft" | Remove-NozNote -WhatIf
/// Get-NozNote -Status seed | Where-Object Topic -eq "draft" | Remove-NozNote -Confirm:$false
/// </example>
[Cmdlet(VerbsCommon.Remove, "NozNote", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
public sealed class RemoveNozNoteCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0,
ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
public string Slug { get; set; } = "";
protected override void ProcessRecord()
{
if (!ShouldProcess(Slug, "Delete note")) return;
Run(Api.DeleteNoteAsync(Slug));
WriteVerbose($"Deleted: {Slug}");
}
}

View file

@ -1,50 +0,0 @@
using System.Management.Automation;
using NozCli.Core.Client;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Rename, "NozNote", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)]
[OutputType(typeof(NozNote))]
public sealed class RenameNozNoteCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string Slug { get; set; } = "";
[Parameter(Mandatory = true, Position = 1)]
public string NewSlug { get; set; } = "";
[Parameter]
public SwitchParameter PassThru { get; set; }
protected override void ProcessRecord()
{
if (Slug == NewSlug)
{
WriteWarning("Quelle und Ziel sind identisch — nichts zu tun.");
return;
}
if (!ShouldProcess(Slug, $"Rename-NozNote → {NewSlug}")) return;
var steps = new List<string>();
var result = Run(NoteRenamer.ExecuteAsync(
Api, Slug, NewSlug,
onProgress: msg => WriteVerbose(msg)));
WriteVerbose($"Fertig: {result.LinksUpdated} Link(s) aktualisiert.");
if (result.LinksUpdated > 0)
{
foreach (var updated in result.UpdatedNotes)
WriteVerbose($" • {updated}");
}
if (PassThru)
{
var notes = Run(Api.GetNotesAsync());
var renamed = notes.Find(n => n.Slug == NewSlug);
if (renamed is not null) WriteObject(NozNote.From(renamed));
}
}
}

View file

@ -1,31 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>Update the markdown content of an existing note.</summary>
/// <example>
/// Set-NozNote -Slug psychologie/test -Markdown "# Test`n`nHello."
/// Get-Content note.md -Raw | Set-NozNote -Slug psychologie/test
/// </example>
[Cmdlet(VerbsCommon.Set, "NozNote", SupportsShouldProcess = true)]
[OutputType(typeof(NozNote))]
public sealed class SetNozNoteCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string Slug { get; set; } = "";
[Parameter(Mandatory = true, Position = 1, ValueFromPipeline = true)]
public string Markdown { get; set; } = "";
protected override void ProcessRecord()
{
if (!ShouldProcess(Slug, "Update note")) return;
Run(Api.PutNoteAsync(Slug, Markdown));
var notes = Run(Api.GetNotesAsync());
var updated = notes.FirstOrDefault(n => n.Slug == Slug);
if (updated is not null)
WriteObject(NozNote.From(updated));
}
}

View file

@ -1,49 +0,0 @@
using System.Management.Automation;
using System.Text.RegularExpressions;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Set, "NozNoteStatus", SupportsShouldProcess = true)]
[OutputType(typeof(NozNote))]
public sealed partial class SetNozNoteStatusCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string Slug { get; set; } = "";
[Parameter(Mandatory = true, Position = 1)]
[ValidateSet("seed", "budding", "evergreen")]
public string Status { get; set; } = "";
[Parameter]
public SwitchParameter PassThru { get; set; }
[GeneratedRegex(@"(?m)^(status:\s*)(?:seed|budding|evergreen)(\s*)$")]
private static partial Regex StatusPattern();
protected override void ProcessRecord()
{
var content = Run(Api.GetNoteAsync(Slug));
var patched = StatusPattern().Replace(content.Markdown, $"$1{Status}$2");
if (patched == content.Markdown)
{
WriteVerbose($"{Slug}: status is already '{Status}', no change.");
if (PassThru) WriteObject(Run(Api.GetNotesAsync()).Find(n => n.Slug == Slug) is { } n
? NozNote.From(n) : null);
return;
}
if (!ShouldProcess(Slug, $"Set status → {Status}")) return;
Run(Api.PutNoteAsync(Slug, patched));
WriteVerbose($"{Slug}: status updated to '{Status}'.");
if (PassThru)
{
var notes = Run(Api.GetNotesAsync());
var updated = notes.Find(n => n.Slug == Slug);
if (updated is not null) WriteObject(NozNote.From(updated));
}
}
}

View file

@ -1,29 +0,0 @@
#Requires -Version 7.2
<#
.SYNOPSIS
Builds and installs the Noz PowerShell module for the current user.
.EXAMPLE
./Install-NozModule.ps1
./Install-NozModule.ps1 -Verbose
#>
$ErrorActionPreference = 'Stop'
$moduleDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell' 'Modules' 'Noz'
Write-Host "Building Noz.PowerShell..." -ForegroundColor Cyan
dotnet build "$PSScriptRoot/Noz.PowerShell.csproj" -c Release -v quiet
$src = "$PSScriptRoot/bin/Release/net8.0"
Write-Host "Installing to: $moduleDir" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $moduleDir | Out-Null
# Copy DLLs and manifest
Copy-Item "$src/Noz.PowerShell.dll" $moduleDir -Force
Copy-Item "$src/NozCli.Core.dll" $moduleDir -Force
Copy-Item "$PSScriptRoot/Noz.psd1" $moduleDir -Force
Write-Host "Done. Import with:" -ForegroundColor Green
Write-Host " Import-Module Noz" -ForegroundColor White
Write-Host " Get-NozNote -Topic psychologie" -ForegroundColor White

View file

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NozCli.Core\NozCli.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PowerShellStandard.Library" Version="5.1.1" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Noz.PowerShell</AssemblyName>
<RootNamespace>Noz.PowerShell</RootNamespace>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
</Project>

View file

@ -1,34 +0,0 @@
@{
ModuleVersion = '0.1.0'
GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
Author = 'Bruno Deanoz'
Description = 'PowerShell module for noz — a self-hosted digital garden'
PowerShellVersion = '7.2'
RootModule = 'Noz.PowerShell.dll'
CmdletsToExport = @(
'Get-NozNote',
'Get-NozNoteContent',
'Set-NozNote',
'New-NozNote',
'Remove-NozNote',
'Get-NozGraph',
'Find-NozNote',
'Measure-NozGarden',
'Set-NozNoteStatus',
'Get-NozOrphans',
'Rename-NozNote',
'Get-NozTemplate',
'Add-NozLink'
)
FunctionsToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('noz', 'digital-garden', 'notes', 'markdown')
ProjectUri = 'https://git.kryptomrx.de/krypto/noz-cli'
}
}
}

View file

@ -1,47 +0,0 @@
using System.Management.Automation;
using NozCli.Core.Client;
using NozCli.Core.Config;
namespace Noz.PowerShell;
public abstract class NozCmdletBase : PSCmdlet
{
protected LocalConfig Config { get; private set; } = null!;
protected NozApiClient Api { get; private set; } = null!;
protected override void BeginProcessing()
{
var cfg = LocalConfig.Load();
if (cfg is null)
ThrowTerminatingError(new ErrorRecord(
new InvalidOperationException(
"noz is not initialized. Run: noz init <server-url> <token>"),
"NozNotInitialized",
ErrorCategory.NotSpecified,
targetObject: null));
Config = cfg!;
Api = new NozApiClient(cfg!);
}
protected override void StopProcessing() => Api?.Dispose();
protected T Run<T>(Task<T> task)
{
try { return task.GetAwaiter().GetResult(); }
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
ThrowTerminatingError(new ErrorRecord(ex, "NozUnauthorized",
ErrorCategory.AuthenticationError, null));
throw;
}
catch (HttpRequestException ex)
{
ThrowTerminatingError(new ErrorRecord(ex, "NozApiError",
ErrorCategory.ConnectionError, null));
throw;
}
}
protected void Run(Task task) => Run(task.ContinueWith(_ => true));
}

View file

@ -1,15 +0,0 @@
namespace Noz.PowerShell;
public sealed class NozGardenStats
{
public int TotalNotes { get; init; }
public int SeedCount { get; init; }
public int BuddingCount { get; init; }
public int EvergreenCount { get; init; }
public int TopicCount { get; init; }
public int LinkCount { get; init; }
public int OrphanCount { get; init; }
public Dictionary<string, int> NotesByTopic { get; init; } = new();
public Dictionary<string, int> NotesByLang { get; init; } = new();
}

View file

@ -1,11 +0,0 @@
namespace Noz.PowerShell;
public sealed class NozGraphEdge
{
public string Source { get; init; } = "";
public string Target { get; init; } = "";
public string SourceTitle { get; init; } = "";
public string TargetTitle { get; init; } = "";
public override string ToString() => $"{Source} → {Target}";
}

View file

@ -1,29 +0,0 @@
using NozCli.Core.Models;
namespace Noz.PowerShell;
/// PS-friendly output object for a noz note.
/// Clean property set — no internal fields like Hash.
public sealed class NozNote
{
public string Slug { get; init; } = "";
public string Title { get; init; } = "";
public string Status { get; init; } = "";
public string Topic { get; init; } = "";
public string Lang { get; init; } = "";
public string? Date { get; init; }
public bool Private { get; init; }
public override string ToString() => Slug;
internal static NozNote From(NoteInfo n) => new()
{
Slug = n.Slug,
Title = n.Title,
Status = n.Status,
Topic = n.Topic,
Lang = n.Lang,
Date = n.Date,
Private = n.Private,
};
}

View file

@ -1,13 +0,0 @@
namespace Noz.PowerShell;
public sealed class NozSearchHit
{
public string Slug { get; init; } = "";
public string Title { get; init; } = "";
public string? Description { get; init; }
public string Topic { get; init; } = "";
public string Status { get; init; } = "";
public string Lang { get; init; } = "";
public override string ToString() => Slug;
}

View file

@ -1,9 +0,0 @@
namespace Noz.PowerShell;
public sealed class NozTemplate
{
public string Name { get; init; } = "";
public string? Description { get; init; }
public override string ToString() => Name;
}

View file

@ -1,60 +0,0 @@
using System.Text.RegularExpressions;
namespace NozCli.Core.Client;
public static class NoteLinkHelper
{
/// Inserts a wikilink to <paramref name="targetSlug"/> into <paramref name="markdown"/>.
/// Appends to an existing section matching <paramref name="sectionHeader"/>,
/// or creates that section at the end of the note if absent.
public static string AddLink(string markdown, string targetSlug, string sectionHeader = "Siehe auch")
{
var link = $"- [[{targetSlug}]]";
// Find the section heading (## Siehe auch, ## See also, etc.)
var sectionMatch = Regex.Match(
markdown,
$@"(?m)^##\s+{Regex.Escape(sectionHeader)}\s*$",
RegexOptions.IgnoreCase);
if (sectionMatch.Success)
{
// Already has this link?
if (markdown.Contains($"[[{targetSlug}]]", StringComparison.Ordinal))
return markdown;
// Insert at the end of this section's content (before next ## heading or EOF)
var afterSection = markdown.IndexOf('\n', sectionMatch.Index + sectionMatch.Length);
if (afterSection < 0) afterSection = markdown.Length;
var nextHeading = Regex.Match(
markdown[afterSection..],
@"(?m)^##\s",
RegexOptions.IgnoreCase);
var insertAt = nextHeading.Success
? afterSection + nextHeading.Index
: markdown.Length;
var before = markdown[..insertAt].TrimEnd();
var after = markdown[insertAt..];
return before + "\n" + link + "\n" + (after.Length > 0 ? "\n" + after.TrimStart() : "");
}
// No section found — create one at the end
return markdown.TrimEnd() + $"\n\n## {sectionHeader}\n\n{link}\n";
}
/// Replaces template placeholders with actual values.
public static string ApplyPlaceholders(
string template, string title, string topic, string lang, string status)
{
return template
.Replace("{{title}}", title, StringComparison.Ordinal)
.Replace("{{topic}}", topic, StringComparison.Ordinal)
.Replace("{{lang}}", lang, StringComparison.Ordinal)
.Replace("{{status}}", status, StringComparison.Ordinal)
.Replace("{{date}}", DateTime.UtcNow.ToString("yyyy-MM-dd"), StringComparison.Ordinal);
}
}

View file

@ -1,125 +0,0 @@
using System.Text.RegularExpressions;
using NozCli.Core.Models;
namespace NozCli.Core.Client;
public static class NoteRenamer
{
public record Result(List<string> UpdatedNotes, int LinksUpdated);
/// Renames a note and rewrites all inbound wikilinks/markdown-links in one atomic sequence.
/// Progress callback receives human-readable step descriptions.
public static async Task<Result> ExecuteAsync(
NozApiClient api,
string oldSlug,
string newSlug,
Action<string>? onProgress = null,
CancellationToken ct = default)
{
// 1. Fetch inbound edges before we change anything
onProgress?.Invoke("Hole Graph-Daten…");
var graph = await api.GetGraphAsync(ct);
var inbound = graph.Edges
.Where(e => e.Target == oldSlug)
.Select(e => e.Source)
.Distinct()
.ToList();
// 2. Fetch the original note
onProgress?.Invoke($"Lese {oldSlug}…");
var original = await api.GetNoteAsync(oldSlug, ct);
// 3. Patch frontmatter topic if the slug prefix (= topic) changes
var patchedContent = PatchTopicIfNeeded(original.Markdown, oldSlug, newSlug);
// 4. Create note under new slug
onProgress?.Invoke($"Erstelle {newSlug}…");
await api.PutNoteAsync(newSlug, patchedContent, ct);
// 5. Update every note that links to the old slug
var updatedNotes = new List<string>();
foreach (var sourceSlug in inbound)
{
onProgress?.Invoke($"Aktualisiere Links in {sourceSlug}…");
try
{
var src = await api.GetNoteAsync(sourceSlug, ct);
var updated = ReplaceReferences(src.Markdown, oldSlug, newSlug);
if (updated != src.Markdown)
{
await api.PutNoteAsync(sourceSlug, updated, ct);
updatedNotes.Add(sourceSlug);
}
}
catch (HttpRequestException)
{
// Don't abort the whole rename if one note fails to update
onProgress?.Invoke($" ⚠ {sourceSlug} konnte nicht aktualisiert werden");
}
}
// 6. Delete old slug — do this last so we never lose data on partial failure
onProgress?.Invoke($"Lösche {oldSlug}…");
await api.DeleteNoteAsync(oldSlug, ct);
return new Result(updatedNotes, updatedNotes.Count);
}
// ── Slug reference replacement ────────────────────────────────────────────
private static string ReplaceReferences(string markdown, string oldSlug, string newSlug)
{
// [[old-slug]] → [[new-slug]]
// [[old-slug|Anzeigetext]] → [[new-slug|Anzeigetext]]
markdown = Regex.Replace(
markdown,
@"\[\[" + Regex.Escape(oldSlug) + @"(\|[^\]]+)?\]\]",
m => $"[[{newSlug}{m.Groups[1].Value}]]");
// [text](/noosphere/old-slug) → [text](/noosphere/new-slug)
// [text](/noosphere/old-slug#anchor) → [text](/noosphere/new-slug#anchor)
markdown = Regex.Replace(
markdown,
@"\[([^\]]+)\]\(/noosphere/" + Regex.Escape(oldSlug) + @"(#[^\)]*)?\)",
m => $"[{m.Groups[1].Value}](/noosphere/{newSlug}{m.Groups[2].Value})");
return markdown;
}
// ── Frontmatter topic patch ───────────────────────────────────────────────
private static string PatchTopicIfNeeded(string markdown, string oldSlug, string newSlug)
{
var oldTopic = FirstSegment(oldSlug);
var newTopic = FirstSegment(newSlug);
if (oldTopic == newTopic || oldTopic is null || newTopic is null)
return markdown;
// Only patch within the frontmatter block
var (fmStart, fmEnd) = FindFrontmatterBounds(markdown);
if (fmStart < 0) return markdown;
var block = markdown[fmStart..fmEnd];
var patched = Regex.Replace(
block,
@"(?m)^(topic:\s*)" + Regex.Escape(oldTopic) + @"(\s*)$",
$"$1{newTopic}$2");
return markdown[..fmStart] + patched + markdown[fmEnd..];
}
private static string? FirstSegment(string slug)
{
var idx = slug.IndexOf('/');
return idx > 0 ? slug[..idx] : null;
}
private static (int start, int end) FindFrontmatterBounds(string markdown)
{
if (!markdown.StartsWith("---", StringComparison.Ordinal)) return (-1, -1);
var closeIdx = markdown.IndexOf("\n---", 3, StringComparison.Ordinal);
if (closeIdx < 0) return (-1, -1);
return (0, closeIdx + 4); // include closing ---
}
}

View file

@ -78,69 +78,6 @@ public class NozApiClient : IDisposable
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
} }
public async Task<List<TemplateInfo>> GetTemplatesAsync(CancellationToken ct = default)
{
var res = await _http.GetFromJsonAsync("api/cli/templates", NozJsonContext.Default.JsonElement, ct);
return res.GetProperty("templates").EnumerateArray().Select(t => new TemplateInfo(
Name: t.GetProperty("name").GetString()!,
Description: t.TryGetProperty("description", out var d) ? d.GetString() : null
)).ToList();
}
public async Task<TemplateContent?> GetTemplateAsync(string name, CancellationToken ct = default)
{
var res = await _http.GetAsync($"api/cli/templates/{Uri.EscapeDataString(name)}", ct);
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
res.EnsureSuccessStatusCode();
var json = await res.Content.ReadFromJsonAsync(NozJsonContext.Default.JsonElement, ct);
return new TemplateContent(
Name: json.GetProperty("name").GetString()!,
Description: json.TryGetProperty("description", out var d) ? d.GetString() : null,
Body: json.GetProperty("body").GetString()!
);
}
public async Task PutFolderIndexAsync(
string folderPath, string title, string? description = null, CancellationToken ct = default)
{
var body = JsonSerializer.Serialize(new FolderBody(title, description), NozJsonContext.Default.FolderBody);
var content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await _http.PutAsync($"api/cli/folders/{folderPath}", content, ct);
res.EnsureSuccessStatusCode();
}
public async Task<GraphData> GetGraphAsync(CancellationToken ct = default)
{
var res = await _http.GetFromJsonAsync("api/cli/graph", NozJsonContext.Default.JsonElement, ct);
var nodes = res.GetProperty("nodes").EnumerateArray().Select(n => new GraphNode(
Id: n.GetProperty("id").GetString()!,
Title: n.GetProperty("title").GetString()!,
Topic: n.GetProperty("topic").GetString()!,
Status: n.GetProperty("status").GetString()!
)).ToList();
var edges = res.GetProperty("edges").EnumerateArray().Select(e => new GraphEdge(
Source: e.GetProperty("source").GetString()!,
Target: e.GetProperty("target").GetString()!
)).ToList();
return new GraphData(nodes, edges);
}
public async Task<List<SearchHit>> SearchAsync(
string query, string? topic = null, int limit = 20, CancellationToken ct = default)
{
var url = $"api/cli/search?q={Uri.EscapeDataString(query)}&limit={limit}";
if (topic is not null) url += $"&topic={Uri.EscapeDataString(topic)}";
var res = await _http.GetFromJsonAsync(url, NozJsonContext.Default.JsonElement, ct);
return res.GetProperty("hits").EnumerateArray().Select(h => new SearchHit(
Slug: h.GetProperty("slug").GetString()!,
Title: h.GetProperty("title").GetString()!,
Description: h.TryGetProperty("description", out var d) ? d.GetString() : null,
Topic: h.GetProperty("topic").GetString()!,
Status: h.GetProperty("status").GetString()!,
Lang: h.GetProperty("lang").GetString()!
)).ToList();
}
public async Task RebuildIndexAsync(CancellationToken ct = default) public async Task RebuildIndexAsync(CancellationToken ct = default)
{ {
var res = await _http.PostAsync("api/cli/rebuild", null, ct); var res = await _http.PostAsync("api/cli/rebuild", null, ct);

View file

@ -1,5 +0,0 @@
namespace NozCli.Core.Models;
public record GraphNode(string Id, string Title, string Topic, string Status);
public record GraphEdge(string Source, string Target);
public record GraphData(List<GraphNode> Nodes, List<GraphEdge> Edges);

View file

@ -1,10 +0,0 @@
namespace NozCli.Core.Models;
public record SearchHit(
string Slug,
string Title,
string? Description,
string Topic,
string Status,
string Lang
);

View file

@ -1,4 +0,0 @@
namespace NozCli.Core.Models;
public record TemplateInfo(string Name, string? Description);
public record TemplateContent(string Name, string? Description, string Body);

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

View file

@ -1,34 +0,0 @@
using System.Text.RegularExpressions;
namespace NozCli.Core.Security;
/// Validates slugs before they are sent to the server or written to disk.
public static partial class SlugGuard
{
// Valid slug: lowercase alphanumeric + hyphens, optionally separated by /
// Examples: "hydroponik", "biosysteme/hydroponik", "a/b/c"
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*(?:/[a-z0-9][a-z0-9\-]*)*$")]
private static partial Regex ValidSlugRegex();
/// Returns true if the slug is safe to use (no path traversal, valid chars).
public static bool IsValid(string slug) =>
!string.IsNullOrWhiteSpace(slug) &&
!slug.Contains("..") &&
!slug.Contains("//") &&
ValidSlugRegex().IsMatch(slug);
/// Resolves a slug to a file path inside baseDir.
/// Returns null if the resulting path would escape baseDir (path traversal).
public static string? ToSafePath(string baseDir, string slug, string extension = ".md")
{
var rel = slug.Replace('/', Path.DirectorySeparatorChar) + extension;
var fullPath = Path.GetFullPath(Path.Combine(baseDir, rel));
var baseFull = Path.GetFullPath(baseDir);
// Ensure the resolved path stays strictly inside baseDir
return fullPath.StartsWith(baseFull + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase)
? fullPath
: null;
}
}

View file

@ -12,14 +12,6 @@ namespace NozCli.Core.Serialization;
[JsonSerializable(typeof(NotesCacheFile))] [JsonSerializable(typeof(NotesCacheFile))]
[JsonSerializable(typeof(List<NoteInfo>))] [JsonSerializable(typeof(List<NoteInfo>))]
[JsonSerializable(typeof(NoteInfo))] [JsonSerializable(typeof(NoteInfo))]
[JsonSerializable(typeof(GraphData))]
[JsonSerializable(typeof(List<GraphNode>))]
[JsonSerializable(typeof(List<GraphEdge>))]
[JsonSerializable(typeof(List<SearchHit>))]
[JsonSerializable(typeof(FolderBody))]
[JsonSerializable(typeof(List<TemplateInfo>))]
[JsonSerializable(typeof(TemplateInfo))]
[JsonSerializable(typeof(TemplateContent))]
public partial class NozJsonContext : JsonSerializerContext { } public partial class NozJsonContext : JsonSerializerContext { }
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)]
@ -27,4 +19,3 @@ public partial class NozJsonContext : JsonSerializerContext { }
internal partial class NozJsonContextPretty : JsonSerializerContext { } internal partial class NozJsonContextPretty : JsonSerializerContext { }
public record PushBody(string Markdown); public record PushBody(string Markdown);
public record FolderBody(string Title, string? Description);

View file

@ -1,86 +0,0 @@
using Xunit;
using NozCli.Core.Client;
namespace NozCli.Tests;
public class NoteLinkHelperTests
{
// ── AddLink — neue Sektion ────────────────────────────────────────────────
[Fact]
public void AddLink_NoSection_AppendsSieheAuch()
{
var md = "# Notiz\n\nEtwas Text.";
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
Assert.Contains("## Siehe auch", result);
Assert.Contains("- [[psychologie/flow]]", result);
}
[Fact]
public void AddLink_NoSection_BodyUntouched()
{
var md = "# Notiz\n\nEtwas Text.";
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
Assert.StartsWith("# Notiz\n\nEtwas Text.", result);
}
// ── AddLink — existierende Sektion ────────────────────────────────────────
[Fact]
public void AddLink_ExistingSection_AppendsToSection()
{
var md = "# Notiz\n\nText.\n\n## Siehe auch\n\n- [[anderes/link]]\n";
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
Assert.Contains("- [[anderes/link]]", result);
Assert.Contains("- [[psychologie/flow]]", result);
}
[Fact]
public void AddLink_AlreadyLinked_NoChange()
{
var md = "# Notiz\n\n## Siehe auch\n\n- [[psychologie/flow]]\n";
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
Assert.Equal(md, result);
}
[Fact]
public void AddLink_ExistingSection_DoesNotBleedIntoNextSection()
{
var md = "# Notiz\n\n## Siehe auch\n\n- [[a]]\n\n## Weiteres\n\nAnderer Inhalt.\n";
var result = NoteLinkHelper.AddLink(md, "b/new-note");
Assert.Contains("- [[b/new-note]]", result);
// The new link should appear before "## Weiteres"
var linkIdx = result.IndexOf("- [[b/new-note]]", StringComparison.Ordinal);
var weitersIdx = result.IndexOf("## Weiteres", StringComparison.Ordinal);
Assert.True(linkIdx < weitersIdx);
}
// ── ApplyPlaceholders ─────────────────────────────────────────────────────
[Fact]
public void ApplyPlaceholders_ReplacesAllTokens()
{
var body = "# {{title}}\n\ntopic: {{topic}}\nlang: {{lang}}\nstatus: {{status}}";
var result = NoteLinkHelper.ApplyPlaceholders(body, "Mein Rezept", "kochen", "de", "seed");
Assert.Contains("# Mein Rezept", result);
Assert.Contains("topic: kochen", result);
Assert.Contains("lang: de", result);
Assert.Contains("status: seed", result);
Assert.DoesNotContain("{{", result);
}
[Fact]
public void ApplyPlaceholders_DateIsCurrentDay()
{
var body = "Datum: {{date}}";
var result = NoteLinkHelper.ApplyPlaceholders(body, "", "", "", "");
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
Assert.Contains(today, result);
}
}

View file

@ -1,102 +0,0 @@
using Xunit;
using System.Reflection;
namespace NozCli.Tests;
// Tests the private ReplaceReferences + PatchTopicIfNeeded methods via reflection
// so we don't expose them publicly just for testing.
public class NoteRenamerTests
{
private static string ReplaceReferences(string md, string oldSlug, string newSlug)
{
var method = typeof(NozCli.Core.Client.NoteRenamer)
.GetMethod("ReplaceReferences", BindingFlags.NonPublic | BindingFlags.Static)!;
return (string)method.Invoke(null, [md, oldSlug, newSlug])!;
}
private static string PatchTopicIfNeeded(string md, string oldSlug, string newSlug)
{
var method = typeof(NozCli.Core.Client.NoteRenamer)
.GetMethod("PatchTopicIfNeeded", BindingFlags.NonPublic | BindingFlags.Static)!;
return (string)method.Invoke(null, [md, oldSlug, newSlug])!;
}
// ── Wikilink replacement ─────────────────────────────────────────────────
[Fact]
public void ReplaceReferences_BareWikilink_IsReplaced()
{
var md = "Siehe [[psychologie/flow]] für Details.";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal("Siehe [[psychologie/new-flow]] für Details.", result);
}
[Fact]
public void ReplaceReferences_WikilinkWithDisplay_PreservesDisplay()
{
var md = "Lies [[psychologie/flow|Flow-Zustand]] weiter.";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal("Lies [[psychologie/new-flow|Flow-Zustand]] weiter.", result);
}
[Fact]
public void ReplaceReferences_MarkdownLink_IsReplaced()
{
var md = "Siehe [Flow](/noosphere/psychologie/flow) hier.";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal("Siehe [Flow](/noosphere/psychologie/new-flow) hier.", result);
}
[Fact]
public void ReplaceReferences_MarkdownLinkWithAnchor_PreservesAnchor()
{
var md = "[Abschnitt](/noosphere/psychologie/flow#abschnitt-2)";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal("[Abschnitt](/noosphere/psychologie/new-flow#abschnitt-2)", result);
}
[Fact]
public void ReplaceReferences_UnrelatedLinks_AreUntouched()
{
var md = "[[other/note]] und [Ext](https://example.com/psychologie/flow)";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal(md, result);
}
[Fact]
public void ReplaceReferences_MultipleOccurrences_AllReplaced()
{
var md = "[[psychologie/flow]] und nochmal [[psychologie/flow|Flow]].";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.DoesNotContain("psychologie/flow]]", result);
Assert.Equal(2, result.Split("psychologie/new-flow").Length - 1);
}
// ── Topic frontmatter patch ───────────────────────────────────────────────
[Fact]
public void PatchTopic_SameTopic_NoChange()
{
var md = "---\ntitle: Flow\ntopic: psychologie\n---\nInhalt";
var result = PatchTopicIfNeeded(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal(md, result);
}
[Fact]
public void PatchTopic_DifferentTopic_UpdatesFrontmatter()
{
var md = "---\ntitle: Flow\ntopic: psychologie\n---\nInhalt";
var result = PatchTopicIfNeeded(md, "psychologie/flow", "biosysteme/flow");
Assert.Contains("topic: biosysteme", result);
Assert.DoesNotContain("topic: psychologie", result);
Assert.Contains("Inhalt", result); // body untouched
}
[Fact]
public void PatchTopic_NoFrontmatter_NoChange()
{
var md = "# Kein Frontmatter\nNur Inhalt";
var result = PatchTopicIfNeeded(md, "psychologie/flow", "biosysteme/flow");
Assert.Equal(md, result);
}
}

View file

@ -1,23 +0,0 @@
using Xunit;
using NozCli.Repl;
namespace NozCli.Tests;
public class NotesCacheTests
{
[Theory]
[InlineData(10, "gerade eben")]
[InlineData(59, "gerade eben")]
[InlineData(60, "vor 1 Min.")]
[InlineData(300, "vor 5 Min.")]
[InlineData(3599, "vor 59 Min.")]
[InlineData(3600, "vor 1 Std.")]
[InlineData(7200, "vor 2 Std.")]
[InlineData(86400, "vor 1 Tagen")]
public void TimeAgo_ReturnsCorrectLabel(int secondsAgo, string expected)
{
var dt = DateTimeOffset.UtcNow.AddSeconds(-secondsAgo);
var result = NotesCache.TimeAgo(dt);
Assert.Equal(expected, result);
}
}

View file

@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NozCli.Core\NozCli.Core.csproj" />
<ProjectReference Include="..\NozCli\NozCli.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Disable AOT for the test project even though NozCli has PublishAot=true -->
<PublishAot>false</PublishAot>
</PropertyGroup>
</Project>

View file

@ -1,165 +0,0 @@
using Xunit;
using NozCli.Core.Config;
using NozCli.Core.Models;
using NozCli.Repl;
namespace NozCli.Tests;
public class ReplContextTests
{
// Builds a ReplContext with a fixed NoteCache — no API calls made.
private static ReplContext BuildCtx(params NoteInfo[] notes)
{
var cfg = new LocalConfig { ServerUrl = "http://localhost:9999", Token = "test" };
var ctx = new ReplContext(null!, cfg); // Api is null — not used in navigation
ctx.NoteCache = [.. notes];
return ctx;
}
private static NoteInfo Note(string slug, string topic, string status = "seed") =>
new(slug, $"Title {slug}", status, topic, "de", false, null);
// ── CurrentPath ──────────────────────────────────────────────────────────
[Fact]
public void CurrentPath_AtRoot_IsWave()
{
var ctx = BuildCtx();
Assert.Equal("~", ctx.CurrentPath);
}
[Fact]
public void CurrentPath_InTopic_ShowsTopic()
{
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
ctx.NavigateTo("psychologie");
Assert.Equal("~/psychologie", ctx.CurrentPath);
}
[Fact]
public void CurrentPath_InSubfolder_ShowsFullPath()
{
var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie"));
ctx.NavigateTo("psychologie");
ctx.NavigateTo("sub");
Assert.Equal("~/psychologie/sub", ctx.CurrentPath);
}
// ── FoldersInView ────────────────────────────────────────────────────────
[Fact]
public void FoldersInView_AtRoot_ReturnsDistinctTopics()
{
var ctx = BuildCtx(
Note("psychologie/a", "psychologie"),
Note("psychologie/b", "psychologie"),
Note("biosysteme/c", "biosysteme"));
var folders = ctx.FoldersInView().OrderBy(f => f).ToList();
Assert.Equal(["biosysteme", "psychologie"], folders);
}
[Fact]
public void FoldersInView_InTopic_ReturnsSubfolders()
{
var ctx = BuildCtx(
Note("psychologie/sub/note", "psychologie"),
Note("psychologie/flat-note", "psychologie"));
ctx.NavigateTo("psychologie");
var folders = ctx.FoldersInView().ToList();
Assert.Contains("sub", folders);
Assert.DoesNotContain("flat-note", folders);
}
// ── NavigateTo / NavigateUp ──────────────────────────────────────────────
[Fact]
public void NavigateTo_ValidTopic_ReturnsTrue()
{
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
Assert.True(ctx.NavigateTo("psychologie"));
Assert.Equal("psychologie", ctx.CurrentTopic);
}
[Fact]
public void NavigateTo_UnknownTopic_ReturnsFalse()
{
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
Assert.False(ctx.NavigateTo("doesnotexist"));
Assert.Null(ctx.CurrentTopic);
}
[Fact]
public void NavigateUp_FromTopic_ReturnsToRoot()
{
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
ctx.NavigateTo("psychologie");
ctx.NavigateUp();
Assert.Null(ctx.CurrentTopic);
Assert.Equal("~", ctx.CurrentPath);
}
[Fact]
public void NavigateUp_FromSubfolder_ReturnsToTopic()
{
var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie"));
ctx.NavigateTo("psychologie");
ctx.NavigateTo("sub");
ctx.NavigateUp();
Assert.Equal("psychologie", ctx.CurrentTopic);
Assert.Null(ctx.CurrentSubPath);
}
// ── SlugRelative ─────────────────────────────────────────────────────────
[Fact]
public void SlugRelative_InTopic_StripsTopicPrefix()
{
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
ctx.NavigateTo("psychologie");
Assert.Equal("flow", ctx.SlugRelative("psychologie/flow"));
}
[Fact]
public void SlugRelative_AtRoot_ReturnsFullSlug()
{
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
Assert.Equal("psychologie/flow", ctx.SlugRelative("psychologie/flow"));
}
// ── NotesInView ──────────────────────────────────────────────────────────
[Fact]
public void NotesInView_InTopic_FiltersCorrectly()
{
var ctx = BuildCtx(
Note("psychologie/a", "psychologie"),
Note("biosysteme/b", "biosysteme"));
ctx.NavigateTo("psychologie");
var notes = ctx.NotesInView().ToList();
Assert.Single(notes);
Assert.Equal("psychologie/a", notes[0].Slug);
}
// ── ResolveSlug ──────────────────────────────────────────────────────────
[Fact]
public void ResolveSlug_ShortName_InTopic_ResolvesToFullSlug()
{
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
ctx.NavigateTo("psychologie");
Assert.Equal("psychologie/flow", ctx.ResolveSlug("flow"));
}
[Fact]
public void ResolveSlug_AlreadyFullSlug_ReturnsUnchanged()
{
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
Assert.Equal("psychologie/flow", ctx.ResolveSlug("psychologie/flow"));
}
}

View file

@ -1,68 +0,0 @@
using Xunit;
using NozCli.Core.Security;
namespace NozCli.Tests;
public class SlugGuardTests
{
// ── IsValid ──────────────────────────────────────────────────────────────
[Theory]
[InlineData("simple-note", true)]
[InlineData("psychologie/test", true)]
[InlineData("biosysteme/sub/deep", true)]
[InlineData("note123", true)]
[InlineData("a", true)]
public void IsValid_AcceptsValidSlugs(string slug, bool expected)
=> Assert.Equal(expected, SlugGuard.IsValid(slug));
[Theory]
[InlineData("../../etc/passwd", false)] // path traversal
[InlineData("../escape", false)] // path traversal
[InlineData("note/../other", false)] // embedded traversal
[InlineData("UPPERCASE", false)] // uppercase not allowed
[InlineData("has space", false)] // spaces not allowed
[InlineData("", false)] // empty
[InlineData(" ", false)] // whitespace
[InlineData("note//double", false)] // double slash
[InlineData("/leading-slash", false)] // leading slash
[InlineData("trailing/", false)] // trailing slash
[InlineData("Sonderzeichen!", false)] // special chars
public void IsValid_RejectsInvalidSlugs(string slug, bool expected)
=> Assert.Equal(expected, SlugGuard.IsValid(slug));
// ── ToSafePath ───────────────────────────────────────────────────────────
[Fact]
public void ToSafePath_ValidSlug_ReturnsPathInsideBase()
{
var baseDir = Path.Combine(Path.GetTempPath(), "noz-test");
var result = SlugGuard.ToSafePath(baseDir, "psychologie/test");
Assert.NotNull(result);
Assert.StartsWith(baseDir, result);
Assert.EndsWith(".md", result);
Assert.Contains("psychologie", result);
}
[Theory]
[InlineData("../../etc/passwd")]
[InlineData("../escape")]
[InlineData("sub/../../outside")]
public void ToSafePath_TraversalSlug_ReturnsNull(string slug)
{
var baseDir = Path.Combine(Path.GetTempPath(), "noz-test");
Assert.Null(SlugGuard.ToSafePath(baseDir, slug));
}
[Fact]
public void ToSafePath_NestedSlug_BuildsCorrectPath()
{
var baseDir = "/tmp/vault";
var result = SlugGuard.ToSafePath(baseDir, "a/b/c");
Assert.NotNull(result);
var expected = Path.Combine("/tmp/vault", "a", "b", "c") + ".md";
Assert.Equal(expected, result);
}
}

View file

@ -1,4 +1,3 @@
using NozCli.Rendering;
using NozCli.Repl; using NozCli.Repl;
using Spectre.Console; using Spectre.Console;
@ -7,15 +6,13 @@ namespace NozCli.Commands;
public class CatCommand : ICommand public class CatCommand : ICommand
{ {
public string Name => "cat"; public string Name => "cat";
public string Description => "Print a note — rendered by default, --raw for plain markdown"; public string Description => "Print the raw markdown of a note";
public string Usage => "cat [--raw] <slug>"; public string Usage => "cat <slug>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{ {
var raw = args.Contains("--raw") || args.Contains("-r"); var slug = args.FirstOrDefault();
var slug = args.FirstOrDefault(a => !a.StartsWith('-')); if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [--raw] <slug>[/]"); return; }
slug = ctx.ResolveSlug(slug); slug = ctx.ResolveSlug(slug);
@ -23,9 +20,6 @@ public class CatCommand : ICommand
await AnsiConsole.Status().StartAsync("Fetching…", async _ => await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
note = await ctx.Api.GetNoteAsync(slug, ct)); note = await ctx.Api.GetNoteAsync(slug, ct));
if (raw) AnsiConsole.WriteLine(note.Markdown);
AnsiConsole.WriteLine(note.Markdown);
else
MarkdownRenderer.Render(note.Markdown);
} }
} }

View file

@ -28,30 +28,22 @@ public class EditCommand : ICommand
var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md"); var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md");
await File.WriteAllTextAsync(tmp, note.Markdown); await File.WriteAllTextAsync(tmp, note.Markdown);
string edited; var (editor, editorArgs) = useCode
try ? ResolveCode(tmp, ctx.Config.CodeEditor)
: ResolveTerminal(tmp, ctx.Config.Editor);
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
var proc = Process.Start(new ProcessStartInfo
{ {
var (editor, editorArgs) = useCode FileName = editor,
? ResolveCode(tmp, ctx.Config.CodeEditor) Arguments = editorArgs,
: ResolveTerminal(tmp, ctx.Config.Editor); UseShellExecute = false,
});
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); proc?.WaitForExit();
var proc = Process.Start(new ProcessStartInfo
{
FileName = editor,
Arguments = editorArgs,
UseShellExecute = false,
});
proc?.WaitForExit();
edited = await File.ReadAllTextAsync(tmp);
}
finally
{
if (File.Exists(tmp)) File.Delete(tmp);
}
var edited = await File.ReadAllTextAsync(tmp);
var newHash = Sha256(edited); var newHash = Sha256(edited);
File.Delete(tmp);
if (newHash == note.Hash) if (newHash == note.Hash)
{ {

View file

@ -1,67 +0,0 @@
using NozCli.Core.Client;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class LinkCommand : ICommand
{
public string Name => "link";
public string Description => "Add a wikilink from one note to another (builds the graph)";
public string Usage => "link <source> <target> [--both] [--section \"Siehe auch\"]";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var both = args.Contains("--both");
var section = ParseFlag(args, "--section") ?? "Siehe auch";
var positional = args.Where(a => !a.StartsWith('-')).ToArray();
if (positional.Length < 2)
{
AnsiConsole.MarkupLine("[red]Usage:[/] link <source> <target> [--both] [--section \"Heading\"]");
return;
}
var source = ctx.ResolveSlug(positional[0]);
var target = ctx.ResolveSlug(positional[1]);
if (source == target)
{
AnsiConsole.MarkupLine("[yellow]Quelle und Ziel sind identisch.[/]");
return;
}
await LinkOneWay(ctx.Api, source, target, section, ct);
if (both)
await LinkOneWay(ctx.Api, target, source, section, ct);
}
private static async Task LinkOneWay(
NozCli.Core.Client.NozApiClient api,
string source, string target, string section, CancellationToken ct)
{
NozCli.Core.Models.NoteContent note = default!;
await AnsiConsole.Status().StartAsync($"Lese {source}…", async _ =>
note = await api.GetNoteAsync(source, ct));
var updated = NoteLinkHelper.AddLink(note.Markdown, target, section);
if (updated == note.Markdown)
{
AnsiConsole.MarkupLine($"[dim]{source}[/] → [dim]{target}[/] [yellow]bereits verlinkt[/]");
return;
}
await AnsiConsole.Status().StartAsync($"Aktualisiere {source}…", async _ =>
await api.PutNoteAsync(source, updated, ct));
AnsiConsole.MarkupLine($"[green]✓[/] [cyan]{source}[/] → [cyan]{target}[/] verlinkt");
}
private static string? ParseFlag(string[] args, string flag)
{
var idx = Array.IndexOf(args, flag);
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}
}

View file

@ -1,80 +0,0 @@
using NozCli.Core.Security;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class MkdirCommand : ICommand
{
public string Name => "mkdir";
public string Description => "Create a new folder (writes _index.md)";
public string Usage => "mkdir <folder-name>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var folderArg = args.FirstOrDefault();
if (folderArg is null)
{
AnsiConsole.MarkupLine("[red]Usage:[/] mkdir <folder-name>");
return;
}
// Resolve full folder path relative to current navigation
var folderPath = ResolveFolderPath(folderArg.TrimEnd('/'), ctx);
if (!SlugGuard.IsValid(folderPath))
{
AnsiConsole.MarkupLine("[red]Ungültiger Ordnername.[/] Nur Kleinbuchstaben, Ziffern und Bindestriche erlaubt.");
return;
}
// Derive a readable title from the last segment
var lastSegment = folderPath.Split('/')[^1];
var title = ToTitle(lastSegment);
await AnsiConsole.Status().StartAsync($"Erstelle [cyan]{folderPath}[/]…", async _ =>
await ctx.Api.PutFolderIndexAsync(folderPath, title, ct: ct));
// Navigate into the new folder
NavigateInto(folderPath, ctx);
AnsiConsole.MarkupLine($"[green]✓[/] Ordner erstellt → {ctx.CurrentPath}");
AnsiConsole.MarkupLine($"[dim]Erstelle deine erste Notiz mit [white]new <name>[/][/]");
}
private static string ResolveFolderPath(string segment, ReplContext ctx)
{
// Already a full multi-segment path (user typed full slug)
if (segment.Contains('/')) return segment;
if (ctx.CurrentTopic is null)
return segment;
if (ctx.CurrentSubPath is null)
return $"{ctx.CurrentTopic}/{segment}";
return $"{ctx.CurrentTopic}/{ctx.CurrentSubPath}/{segment}";
}
private static void NavigateInto(string folderPath, ReplContext ctx)
{
var parts = folderPath.Split('/');
if (parts.Length == 1)
{
ctx.CurrentTopic = parts[0];
ctx.CurrentSubPath = null;
}
else
{
ctx.CurrentTopic = parts[0];
ctx.CurrentSubPath = string.Join('/', parts[1..]);
}
}
// "new-subfolder" → "New Subfolder"
private static string ToTitle(string slug) =>
string.Join(' ', slug.Split('-').Select(Capitalize));
private static string Capitalize(string s) =>
s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..];
}

View file

@ -1,88 +0,0 @@
using NozCli.Core.Client;
using NozCli.Core.Security;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class MvCommand : ICommand
{
public string Name => "mv";
public string Description => "Rename a note and update all inbound links";
public string Usage => "mv <old-name> <new-name>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
if (args.Length < 2)
{
AnsiConsole.MarkupLine("[red]Usage:[/] mv <old-name> <new-name>");
return;
}
// Resolve short names to full slugs
var oldSlug = ctx.ResolveSlug(args[0]);
var newSlug = ctx.ResolveNewSlug(args[1]);
if (!SlugGuard.IsValid(oldSlug) || !SlugGuard.IsValid(newSlug))
{
AnsiConsole.MarkupLine("[red]Ungültiger Slug.[/] Nur Kleinbuchstaben, Ziffern und Bindestriche erlaubt.");
return;
}
if (oldSlug == newSlug)
{
AnsiConsole.MarkupLine("[yellow]Quelle und Ziel sind identisch — nichts zu tun.[/]");
return;
}
if (!ctx.NoteCache.Any(n => n.Slug == oldSlug))
{
AnsiConsole.MarkupLine($"[red]Notiz nicht gefunden:[/] {oldSlug}");
return;
}
if (ctx.NoteCache.Any(n => n.Slug == newSlug))
{
AnsiConsole.MarkupLine($"[red]Ziel existiert bereits:[/] {newSlug}");
return;
}
AnsiConsole.MarkupLine($"[dim]Umbenennen:[/] [cyan]{oldSlug}[/] → [green]{newSlug}[/]");
NoteRenamer.Result result = default!;
await AnsiConsole.Progress()
.AutoClear(false)
.HideCompleted(false)
.StartAsync(async prog =>
{
var task = prog.AddTask("Umbenennen…", maxValue: 100);
result = await NoteRenamer.ExecuteAsync(
ctx.Api, oldSlug, newSlug,
onProgress: msg =>
{
task.Description = msg;
task.Increment(20);
},
ct: ct);
task.Value = 100;
task.Description = "Fertig";
});
// Update local cache: swap old entry for a patched new one
var oldEntry = ctx.NoteCache.FirstOrDefault(n => n.Slug == oldSlug);
if (oldEntry is not null)
{
ctx.NoteCache.Remove(oldEntry);
ctx.NoteCache.Add(oldEntry with { Slug = newSlug });
}
var linkInfo = result.LinksUpdated > 0
? $" [dim]· {result.LinksUpdated} Link(s) aktualisiert[/]"
: "";
AnsiConsole.MarkupLine($"[green]✓[/] {oldSlug} → {newSlug}{linkInfo}");
}
}

View file

@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using NozCli.Core.Client; using System.Security.Cryptography;
using System.Text;
using NozCli.Core.Models; using NozCli.Core.Models;
using NozCli.Repl; using NozCli.Repl;
using Spectre.Console; using Spectre.Console;
@ -10,20 +11,19 @@ public class NewCommand : ICommand
{ {
public string Name => "new"; public string Name => "new";
public string Description => "Create a new note and open it in nano (or -c for VS Code)"; public string Description => "Create a new note and open it in nano (or -c for VS Code)";
public string Usage => "new [-c] [--template <name>] <slug-or-name>"; public string Usage => "new [-c] <slug-or-name>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{ {
var useCode = args.Contains("-c") || args.Contains("--code"); var useCode = args.Contains("-c") || args.Contains("--code");
var templateName = ParseFlag(args, "--template") ?? ParseFlag(args, "-t"); var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-')); if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] <slug-or-name>[/]"); return; }
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] [--template <name>] <slug-or-name>[/]"); return; }
var slug = ctx.ResolveNewSlug(nameArg); var slug = ctx.ResolveNewSlug(nameArg);
if (ctx.NoteCache.Any(n => n.Slug == slug)) if (ctx.NoteCache.Any(n => n.Slug == slug))
{ {
AnsiConsole.MarkupLine($"[yellow]Notiz existiert bereits:[/] {slug} [dim](use 'edit' to modify it)[/]"); AnsiConsole.MarkupLine($"[yellow]Note already exists:[/] {slug} [dim](use 'edit' to modify it)[/]");
return; return;
} }
@ -31,27 +31,6 @@ public class NewCommand : ICommand
var title = ToTitle(slug.Split('/').Last()); var title = ToTitle(slug.Split('/').Last());
var lang = "de"; var lang = "de";
string noteBody;
if (templateName is not null)
{
TemplateContent? tmpl = null;
await AnsiConsole.Status().StartAsync($"Lade Template '{templateName}'…", async _ =>
tmpl = await ctx.Api.GetTemplateAsync(templateName, ct));
if (tmpl is null)
{
AnsiConsole.MarkupLine($"[red]Template '{templateName}' nicht gefunden.[/] Verfügbare Templates mit [white]templates[/] anzeigen.");
return;
}
noteBody = NoteLinkHelper.ApplyPlaceholders(tmpl.Body, title, topic, lang, "seed");
}
else
{
noteBody = $"# {title}\n\n";
}
var template = $""" var template = $"""
--- ---
title: "{title}" title: "{title}"
@ -61,34 +40,28 @@ public class NewCommand : ICommand
lang: {lang} lang: {lang}
--- ---
{noteBody.TrimStart()} # {title}
"""; """;
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md"); var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
await File.WriteAllTextAsync(tmp, template); await File.WriteAllTextAsync(tmp, template);
string markdown; var (editor, editorArgs) = useCode
try ? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor)
{ : EditCommand.ResolveTerminal(tmp, ctx.Config.Editor);
var (editor, editorArgs) = useCode
? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor)
: EditCommand.ResolveTerminal(tmp, ctx.Config.Editor);
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
var proc = Process.Start(new ProcessStartInfo var proc = Process.Start(new ProcessStartInfo
{
FileName = editor,
Arguments = editorArgs,
UseShellExecute = false,
});
proc?.WaitForExit();
markdown = await File.ReadAllTextAsync(tmp);
}
finally
{ {
if (File.Exists(tmp)) File.Delete(tmp); FileName = editor,
} Arguments = editorArgs,
UseShellExecute = false,
});
proc?.WaitForExit();
var markdown = await File.ReadAllTextAsync(tmp);
File.Delete(tmp);
if (markdown.Trim() == template.Trim()) if (markdown.Trim() == template.Trim())
{ {
@ -112,9 +85,6 @@ public class NewCommand : ICommand
string.Join(' ', slug.Split('-').Select(w => string.Join(' ', slug.Split('-').Select(w =>
w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w)); w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w));
private static string? ParseFlag(string[] args, string flag) private static string Sha256(string text) =>
{ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower();
var idx = Array.IndexOf(args, flag);
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}
} }

View file

@ -1,5 +1,4 @@
using NozCli.Repl; using NozCli.Repl;
using NozCli.Core.Security;
using Spectre.Console; using Spectre.Console;
namespace NozCli.Commands; namespace NozCli.Commands;
@ -55,13 +54,6 @@ public class PushCommand : ICommand
var relative = Path.GetRelativePath(localPath, file); var relative = Path.GetRelativePath(localPath, file);
var slug = relative.Replace(Path.DirectorySeparatorChar, '/').Replace(".md", ""); var slug = relative.Replace(Path.DirectorySeparatorChar, '/').Replace(".md", "");
if (!SlugGuard.IsValid(slug))
{
AnsiConsole.MarkupLine($"[yellow]⚠ Skipped invalid slug:[/] {slug}");
task.Increment(1);
continue;
}
var markdown = await File.ReadAllTextAsync(file); var markdown = await File.ReadAllTextAsync(file);
var localHash = NozCli.Core.Client.HashHelper.Sha256(markdown); var localHash = NozCli.Core.Client.HashHelper.Sha256(markdown);

View file

@ -1,61 +0,0 @@
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class SearchCommand : ICommand
{
public string Name => "search";
public string Description => "Full-text search via Meilisearch";
public string Usage => "search <query> [--topic <topic>] [--limit <n>]";
private static readonly Dictionary<string, string> StatusDot = new()
{
["seed"] = "[yellow]◦[/]",
["budding"] = "[magenta]◈[/]",
["evergreen"] = "[cyan]◉[/]",
};
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var topic = ParseFlag(args, "--topic") ?? ctx.CurrentTopic;
var limitRaw = ParseFlag(args, "--limit");
var limit = limitRaw is not null && int.TryParse(limitRaw, out var l) ? l : 10;
var query = string.Join(' ', args.Where(a => !a.StartsWith('-') &&
a != ParseFlag(args, "--topic") &&
a != ParseFlag(args, "--limit")));
if (string.IsNullOrWhiteSpace(query))
{
AnsiConsole.MarkupLine("[red]Usage:[/] search <query> [--topic <topic>] [--limit <n>]");
return;
}
List<NozCli.Core.Models.SearchHit> hits = [];
await AnsiConsole.Status().StartAsync($"Suche nach »{query}«…", async _ =>
hits = await ctx.Api.SearchAsync(query, topic, limit, ct));
if (hits.Count == 0)
{
AnsiConsole.MarkupLine($"[dim]Keine Treffer für »{Markup.Escape(query)}«.[/]");
return;
}
AnsiConsole.MarkupLine($"[dim]{hits.Count} Treffer[/]\n");
foreach (var h in hits)
{
var dot = StatusDot.GetValueOrDefault(h.Status, "·");
var desc = h.Description is { Length: > 0 } d ? $"\n [dim]{Markup.Escape(d)}[/]" : "";
AnsiConsole.MarkupLine($" {dot} [cyan]{Markup.Escape(h.Slug)}[/] [white]{Markup.Escape(h.Title)}[/]{desc}");
}
AnsiConsole.WriteLine();
}
private static string? ParseFlag(string[] args, string flag)
{
var idx = Array.IndexOf(args, flag);
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}
}

View file

@ -1,7 +1,6 @@
using NozCli.Core.Client; using NozCli.Core.Client;
using NozCli.Core.Models; using NozCli.Core.Models;
using NozCli.Repl; using NozCli.Repl;
using NozCli.Core.Security;
using Spectre.Console; using Spectre.Console;
namespace NozCli.Commands; namespace NozCli.Commands;
@ -52,13 +51,7 @@ public class SyncCommand : ICommand
foreach (var note in noteList) foreach (var note in noteList)
{ {
if (!SlugGuard.IsValid(note.Slug)) var filePath = SlugToPath(localPath, note.Slug);
{
AnsiConsole.MarkupLine($"[yellow]⚠ Skipped unsafe slug from server:[/] {note.Slug}");
continue;
}
var filePath = SlugGuard.ToSafePath(localPath, note.Slug);
if (!force && note.Hash is not null && File.Exists(filePath)) if (!force && note.Hash is not null && File.Exists(filePath))
{ {
@ -111,7 +104,7 @@ public class SyncCommand : ICommand
try try
{ {
var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt); var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt);
var filePath = SlugGuard.ToSafePath(localPath, note.Slug)!; var filePath = SlugToPath(localPath, note.Slug);
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
await File.WriteAllTextAsync(filePath, content.Markdown, innerCt); await File.WriteAllTextAsync(filePath, content.Markdown, innerCt);
@ -143,4 +136,6 @@ public class SyncCommand : ICommand
} }
} }
private static string SlugToPath(string baseDir, string slug) =>
Path.Combine(baseDir, slug.Replace('/', Path.DirectorySeparatorChar) + ".md");
} }

View file

@ -1,39 +0,0 @@
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class TemplatesCommand : ICommand
{
public string Name => "templates";
public string Description => "List available note templates";
public string Usage => "templates";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
List<NozCli.Core.Models.TemplateInfo> templates = [];
await AnsiConsole.Status().StartAsync("Lade Templates…", async _ =>
templates = await ctx.Api.GetTemplatesAsync(ct));
if (templates.Count == 0)
{
AnsiConsole.MarkupLine("[dim]Keine Templates vorhanden.[/]");
AnsiConsole.MarkupLine("[dim]Lege Templates in [white]content/noosphere/_templates/<name>.md[/] an.[/]");
return;
}
var table = new Table()
.AddColumn("[dim]Name[/]")
.AddColumn("[dim]Beschreibung[/]")
.Border(TableBorder.None)
.HideHeaders();
foreach (var t in templates.OrderBy(t => t.Name))
table.AddRow(
$"[cyan]{Markup.Escape(t.Name)}[/]",
Markup.Escape(t.Description ?? "—"));
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[dim]Verwenden mit:[/] [white]new --template <name> <notiz-name>[/]");
}
}

View file

@ -1,195 +0,0 @@
using System.Text;
using System.Text.RegularExpressions;
using Spectre.Console;
namespace NozCli.Rendering;
/// Terminal markdown renderer — converts common markdown to Spectre.Console output.
/// Handles: headings, bold/italic, inline code, fenced code blocks,
/// bullet/numbered lists, blockquotes, horizontal rules.
public static partial class MarkdownRenderer
{
public static void Render(string markdown)
{
var lines = markdown.ReplaceLineEndings("\n").Split('\n');
var inCode = false;
var codeLang = "";
var codeBlock = new StringBuilder();
AnsiConsole.WriteLine();
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
// ── Fenced code block ────────────────────────────────────────────
if (line.TrimStart().StartsWith("```"))
{
if (!inCode)
{
inCode = true;
codeLang = line.TrimStart()[3..].Trim();
codeBlock.Clear();
}
else
{
inCode = false;
RenderCodeBlock(codeBlock.ToString().TrimEnd(), codeLang);
}
continue;
}
if (inCode)
{
codeBlock.AppendLine(line);
continue;
}
// ── Horizontal rule ──────────────────────────────────────────────
if (HrRegex().IsMatch(line))
{
AnsiConsole.Write(new Rule() { Style = new Style(new Color(60, 60, 80)) });
continue;
}
// ── Headings ─────────────────────────────────────────────────────
if (line.StartsWith("### "))
{
AnsiConsole.MarkupLine($"\n [bold white]{Markup.Escape(line[4..])}[/]");
continue;
}
if (line.StartsWith("## "))
{
AnsiConsole.MarkupLine($"\n[bold cyan]{Markup.Escape(line[3..])}[/]");
continue;
}
if (line.StartsWith("# "))
{
AnsiConsole.MarkupLine($"\n[bold underline cyan]{Markup.Escape(line[2..])}[/]\n");
continue;
}
// ── Blockquote ───────────────────────────────────────────────────
if (line.StartsWith("> "))
{
var content = ApplyInline(line[2..]);
AnsiConsole.MarkupLine($"[grey46]┃[/] [dim italic]{content}[/]");
continue;
}
// ── Bullet list ──────────────────────────────────────────────────
var bulletMatch = BulletRegex().Match(line);
if (bulletMatch.Success)
{
var indent = bulletMatch.Groups[1].Value.Length / 2;
var content = ApplyInline(bulletMatch.Groups[2].Value);
var pad = new string(' ', indent * 2);
AnsiConsole.MarkupLine($"{pad} [dim cyan]•[/] {content}");
continue;
}
// ── Numbered list ────────────────────────────────────────────────
var numMatch = NumberedRegex().Match(line);
if (numMatch.Success)
{
var num = numMatch.Groups[1].Value;
var content = ApplyInline(numMatch.Groups[2].Value);
AnsiConsole.MarkupLine($" [dim cyan]{num}.[/] {content}");
continue;
}
// ── Empty line ───────────────────────────────────────────────────
if (string.IsNullOrWhiteSpace(line))
{
AnsiConsole.WriteLine();
continue;
}
// ── Paragraph ────────────────────────────────────────────────────
AnsiConsole.MarkupLine($" {ApplyInline(line)}");
}
AnsiConsole.WriteLine();
}
// ── Code block rendering ─────────────────────────────────────────────────
private static void RenderCodeBlock(string code, string lang)
{
var header = lang.Length > 0 ? $"[dim]{Markup.Escape(lang)}[/]" : "[dim]code[/]";
var panel = new Panel(new Text(code, new Style(new Color(200, 200, 220))))
{
Header = new PanelHeader($" {header} "),
Border = BoxBorder.Rounded,
BorderStyle = new Style(new Color(60, 60, 80)),
Padding = new Padding(1, 0),
};
AnsiConsole.WriteLine();
AnsiConsole.Write(panel);
}
// ── Inline formatting ────────────────────────────────────────────────────
private static string ApplyInline(string raw)
{
// Collect all pattern matches, escape text between them.
// This ensures brackets/special chars in regular text don't break Spectre markup.
(Regex regex, Func<Match, string> format)[] patterns =
[
(InlineCodeRegex(), m => $"[bold cyan on grey7] {Markup.Escape(m.Groups[1].Value)} [/]"),
(BoldRegex(), m => $"[bold]{Markup.Escape(m.Groups[1].Value)}[/]"),
(ItalicRegex(), m => $"[italic]{Markup.Escape(m.Groups[1].Value)}[/]"),
];
var spans = new List<(int start, int end, string replacement)>();
foreach (var (regex, format) in patterns)
{
foreach (Match m in regex.Matches(raw))
{
// Skip if overlapping with an already-claimed span
if (spans.Exists(s => m.Index < s.end && m.Index + m.Length > s.start))
continue;
spans.Add((m.Index, m.Index + m.Length, format(m)));
}
}
spans.Sort((a, b) => a.start.CompareTo(b.start));
var sb = new StringBuilder();
var pos = 0;
foreach (var (start, end, replacement) in spans)
{
if (pos < start)
sb.Append(Markup.Escape(raw[pos..start]));
sb.Append(replacement);
pos = end;
}
if (pos < raw.Length)
sb.Append(Markup.Escape(raw[pos..]));
return sb.ToString();
}
// ── Compiled regexes (AOT-safe via [GeneratedRegex]) ────────────────────
[GeneratedRegex(@"^[-*_]{3,}\s*$")]
private static partial Regex HrRegex();
[GeneratedRegex(@"^( *)[-*+] (.+)$")]
private static partial Regex BulletRegex();
[GeneratedRegex(@"^(\d+)\. (.+)$")]
private static partial Regex NumberedRegex();
[GeneratedRegex(@"`([^`]+)`")]
private static partial Regex InlineCodeRegex();
[GeneratedRegex(@"\*\*([^*]+)\*\*")]
private static partial Regex BoldRegex();
[GeneratedRegex(@"(?<!\*)\*(?!\*)([^*]+)\*(?!\*)")]
private static partial Regex ItalicRegex();
}

View file

@ -23,11 +23,6 @@ public class CommandRegistry
new RmCommand(), new RmCommand(),
new ConfigCommand(), new ConfigCommand(),
new TreeCommand(), new TreeCommand(),
new MkdirCommand(),
new MvCommand(),
new SearchCommand(),
new LinkCommand(),
new TemplatesCommand(),
new VersionCommand(), new VersionCommand(),
new ClearCommand(), new ClearCommand(),
}; };

View file

@ -35,9 +35,6 @@ public static class NotesCache
var tmp = CachePath + ".tmp"; var tmp = CachePath + ".tmp";
await File.WriteAllTextAsync(tmp, json); await File.WriteAllTextAsync(tmp, json);
File.Move(tmp, CachePath, overwrite: true); File.Move(tmp, CachePath, overwrite: true);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(CachePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
} }
catch { /* cache write failures are non-fatal */ } catch { /* cache write failures are non-fatal */ }
} }

View file

@ -70,7 +70,6 @@ public class ReplContext
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length; var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
return NotesInView() return NotesInView()
.Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1)
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth)) .Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
.Where(s => s is not null) .Where(s => s is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)!; .Distinct(StringComparer.OrdinalIgnoreCase)!;

239
README.md
View file

@ -1,214 +1,79 @@
# noz-cli # noz-cli
A terminal REPL and PowerShell module for [noz](https://git.kryptomrx.de/krypto/noz) — a self-hosted digital garden built with Next.js. A terminal REPL for [noz](https://github.com/kryptomrx/noz) — a self-hosted digital garden built with Next.js.
Navigate, create, and manage your notes from the command line. Ships as a Native AOT binary (~7.6 MB, no .NET runtime required) and a PowerShell module for scripting and automation. Navigate, read, and edit your notes from the command line. Native AOT binary, no .NET runtime required.
--- ---
## Install ## Install
### macOS / Linux — binary Download the latest binary from [Releases](../../releases) and place it on your PATH:
Download from [Releases](../../releases) and place on your PATH:
```bash ```bash
# macOS Apple Silicon # macOS (Apple Silicon)
curl -L https://git.kryptomrx.de/krypto/noz-cli/releases/latest/download/noz-osx-arm64 \ curl -L https://github.com/kryptomrx/noz-cli/releases/latest/download/noz-osx-arm64 -o /usr/local/bin/noz
-o /usr/local/bin/noz && chmod +x /usr/local/bin/noz chmod +x /usr/local/bin/noz
``` ```
### PowerShell module
Requires PowerShell 7.2+ and .NET 8.
```powershell
git clone https://git.kryptomrx.de/krypto/noz-cli
cd noz-cli
./Noz.PowerShell/Install-NozModule.ps1
```
---
## Setup ## Setup
```bash ```bash
noz init https://your-noz-instance.com your-api-token noz init https://your-noz-instance.com your-api-token
``` ```
Writes `~/.config/noz/config.json`. The token is the CLI token configured in your `noosphere.toml` under `[cli]`. This writes `~/.config/noz/config.json`.
--- ## Usage
## REPL ```
```bash
noz noz
``` ```
Starts the interactive REPL. A local note cache (`~/.cache/noz/notes.json`) is used for instant startup; a background refresh runs silently. Starts the interactive REPL. Type `help` for a list of commands.
### Navigation ### Navigation
| Command | Description | ```
|---------|-------------| ~ » ls # list topics
| `ls` | List topics (at root) or notes in the current folder | ~ » cd psychologie # enter a topic
| `ls -la` | Detailed list with status, date, language | ~/psychologie » ls -la # detailed view with status, date, title
| `cd <topic>` | Enter a topic or sub-folder | ~/psychologie » tree # full hierarchy as a tree
| `cd ..` | Go up one level | ~/psychologie » cd .. # go up
| `tree` | Full hierarchy as a tree | ```
### Notes ### Notes
| Command | Description | ```
|---------|-------------| ~ » cat psychologie/flow-theorie # print raw markdown
| `cat <slug>` | Print a note (rendered markdown) | ~ » edit flow-theorie # open in terminal editor
| `cat --raw <slug>` | Print raw markdown | ~ » edit -c flow-theorie # open in VS Code
| `new <name>` | Create a note and open in editor | ~ » new meine-neue-notiz # create note from template
| `new -c <name>` | Create a note and open in VS Code | ~ » rm alte-notiz # delete a note
| `new --template <name> <slug>` | Create from a template | ```
| `edit <slug>` | Edit a note in terminal editor |
| `edit -c <slug>` | Edit in VS Code |
| `mv <old> <new>` | Rename a note — rewrites all inbound wikilinks |
| `rm <slug>` | Delete a note |
| `search <query>` | Full-text search (Meilisearch) |
| `search <query> --topic <topic>` | Search within a topic |
### Folders
| Command | Description |
|---------|-------------|
| `mkdir <name>` | Create a folder (writes `_index.md`, navigates in) |
### Linking
| Command | Description |
|---------|-------------|
| `link <source> <target>` | Add a wikilink from source to target |
| `link <source> <target> --both` | Bidirectional link |
### Templates
| Command | Description |
|---------|-------------|
| `templates` | List available templates |
| `new --template <name> <slug>` | Create a note from a template |
Templates live in `content/noosphere/_templates/<name>.md` on the server. Placeholders: `{{title}}`, `{{topic}}`, `{{date}}`, `{{status}}`, `{{lang}}`.
### Sync ### Sync
| Command | Description | ```
|---------|-------------| ~ » sync ~/vault # pull changed notes to local folder
| `sync <local-path>` | Pull changed notes from server to local folder | ~ » sync ~/vault --all # pull everything (ignore local hashes)
| `sync <local-path> --all` | Pull everything (ignore local hashes) | ~ » sync ~/vault --dry-run # preview what would be downloaded
| `sync <local-path> --dry-run` | Preview what would be downloaded | ~ » push ~/vault # push local .md files to server
| `push <local-path>` | Push local `.md` files to server | ```
| `status <local-path>` | Show diff between local folder and server |
### Other ### Other
| Command | Description |
|---------|-------------|
| `version` | Show version and git hash |
| `config` | Show editor settings |
| `config editor <name>` | Set terminal editor |
| `config code-editor <name>` | Set GUI editor |
| `clear` | Clear the screen |
| `help` | List all commands |
| `exit` | Quit |
**Ctrl+C** while idle exits the REPL. During a command it cancels only that command.
---
## PowerShell Module
After installing, import with:
```powershell
Import-Module Noz
``` ```
~ » version # show version + git hash
### Note commands ~ » config # show editor settings
~ » config editor nano # set terminal editor
```powershell ~ » config code-editor code # set GUI editor
# List and filter notes ~ » exit
Get-NozNote
Get-NozNote -Topic psychologie
Get-NozNote -Status evergreen
Get-NozNote -Lang de
# Read content
Get-NozNoteContent -Slug psychologie/flow-theorie
# Create / update / delete
New-NozNote -Slug kochen/pasta -Title "Pasta Carbonara" -Topic kochen
Set-NozNote -Slug psychologie/flow-theorie -Markdown $content
Remove-NozNote -Slug alte-notiz
# Rename (rewrites all inbound links)
Rename-NozNote -Slug psychologie/old -NewSlug psychologie/new
# Change status
Set-NozNoteStatus -Slug psychologie/flow-theorie -Status evergreen
``` ```
### Search
```powershell
Find-NozNote "flow"
Find-NozNote "hydroponik" -Topic biosysteme -Limit 5
```
### Graph & analysis
```powershell
# All wikilink edges
Get-NozGraph
Get-NozGraph -Source psychologie/flow
# Notes with no incoming links
Get-NozOrphans
Get-NozOrphans -Topic psychologie
# Garden-wide statistics
Measure-NozGarden
# Link two notes
Add-NozLink -Source psychologie/flow -Target biosysteme/brs-konzept
Add-NozLink -Source a -Target b -Both
```
### Templates
```powershell
Get-NozTemplate
Get-NozTemplate rezept
```
### Pipeline examples
```powershell
# Promote all seed notes in a topic to budding
Get-NozNote -Topic psychologie -Status seed |
Set-NozNoteStatus -Status budding -WhatIf
# Export all evergreen notes as markdown files
Get-NozNote -Status evergreen |
ForEach-Object { Get-NozNoteContent $_.Slug } |
ForEach-Object { Set-Content "$($_.Slug).md" $_.Markdown }
# Find orphans and link them to a topic overview
Get-NozOrphans -Topic biosysteme |
Add-NozLink -Target biosysteme/uebersicht
```
---
## Configuration ## Configuration
`~/.config/noz/config.json`: Editor preferences are stored in `~/.config/noz/config.json`:
```json ```json
{ {
@ -219,41 +84,19 @@ Get-NozOrphans -Topic biosysteme |
} }
``` ```
Editor is auto-detected from `$EDITOR` if not set. VS Code, Zed, and Sublime Text are detected automatically for `-c` mode.
---
## Server-side setup
The CLI needs the `[cli]` block in your `noosphere.toml`:
```toml
[cli]
token = "your-secret-token"
allow_push = true
allow_delete = true
allow_vault = false
allow_rebuild = false
```
---
## Building from source ## Building from source
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download) and `clang`. Requires [.NET 10 SDK](https://dotnet.microsoft.com/download) and `clang`.
```bash ```bash
git clone https://git.kryptomrx.de/krypto/noz-cli git clone https://github.com/kryptomrx/noz-cli
cd noz-cli cd noz-cli
# Run without AOT # Run (no AOT)
dotnet run --project NozCli dotnet run --project NozCli
# Native AOT binary — macOS Apple Silicon # Native AOT binary (macOS arm64)
dotnet publish NozCli/NozCli.csproj -r osx-arm64 -c Release dotnet publish NozCli/NozCli.csproj -r osx-arm64 -c Release
# Run tests
dotnet test NozCli.Tests/NozCli.Tests.csproj -c Release
``` ```
On macOS, AOT linking requires OpenSSL via Homebrew: On macOS, AOT linking requires OpenSSL via Homebrew:
@ -262,9 +105,7 @@ On macOS, AOT linking requires OpenSSL via Homebrew:
brew install openssl brew install openssl
``` ```
---
## License ## License
[Polyform Noncommercial 1.0.0](LICENSE) — free for personal and non-commercial use. [Polyform Noncommercial 1.0.0](LICENSE) — free for personal and non-commercial use.
Commercial licensing: [bruno.deanoz@protonmail.com](mailto:bruno.deanoz@protonmail.com). For commercial licensing, contact [Bruno Deanoz](mailto:bruno.deanoz1@gmail.com).

View file

@ -1,6 +1,4 @@
<Solution> <Solution>
<Project Path="NozCli.Core/NozCli.Core.csproj" /> <Project Path="NozCli.Core/NozCli.Core.csproj" />
<Project Path="NozCli/NozCli.csproj" /> <Project Path="NozCli/NozCli.csproj" />
<Project Path="Noz.PowerShell/Noz.PowerShell.csproj" />
<Project Path="NozCli.Tests/NozCli.Tests.csproj" />
</Solution> </Solution>