Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/nix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Nix
on:
pull_request:
push:
branches: [main]

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/determinate-nix-action@v3
# `nix flake check` rather than `nix build`: it builds the package *and*
# evaluates every `checks.*`, so the help smoke test and the completions
# contract actually gate the PR.
- run: nix flake check --print-build-logs
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ notes/agent-onboarding.md
# Nathan's rule: execution over planning-docs-in-PRs. Local working
# reference for the smalltalk→st cutover — never committed.
notes/rename-cutover.md

# `nix build` output symlink
/result
82 changes: 82 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

203 changes: 203 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
{
description = "smalltalk - file-folder coordination CLI and TypeScript API";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
pty.url = "github:compoundingtech/pty";
pty.inputs.nixpkgs.follows = "nixpkgs";
};

outputs =
{
self,
nixpkgs,
flake-utils,
pty,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };

# package.json is the single source of truth for the version, so a
# release bump does not need a matching edit here. Reconciling this
# with a richer build identity (git rev, dirty marker) is issue #103.
version = (builtins.fromJSON (builtins.readFile ./package.json)).version;

# npm names of local `file:` deps that resolve to sibling repos, mapped
# to the store path holding their package root. buildNpmPackage cannot
# fetch these, so they are linked into node_modules after the copy.
# The key must match package.json / package-lock.json exactly, since
# that is the specifier Node resolves.
siblingLinks = {
"@compoundingtech/pty" = "${pty.packages.${system}.default}/lib/pty";
};

# The shells `st completions <shell>` can generate. The generator emits
# `complete -c st` etc. unconditionally, so every script is for `st`
# regardless of which bin name invoked it.
completionShells = [
"bash"
"zsh"
"fish"
];

smalltalk = pkgs.buildNpmPackage {
pname = "smalltalk";
inherit version;
src = self;

# TODO(rust): a Rust rewrite drops npm entirely, and this hash with it.
# Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json
npmDepsHash = "sha256-CfgSssHEdSjL9liKjaw9aYKMtCJdaxWKGbFM59PgNwg=";

nodejs = pkgs.nodejs_24;
npmInstallFlags = [ "--omit=dev" ];
# Sources ship as TypeScript and run through node's type stripping;
# there is no compile step to produce a dist/.
dontNpmBuild = true;

nativeBuildInputs = [ pkgs.installShellFiles ];

installPhase = ''
runHook preInstall

mkdir -p $out/lib/smalltalk
cp -r . $out/lib/smalltalk

# TODO(rust): sibling linking exists only because npm cannot resolve
# `file:` deps in a sandbox; a Cargo build has no equivalent.
${pkgs.lib.concatLines (
pkgs.lib.mapAttrsToList (npmName: storePath: ''
mkdir -p "$out/lib/smalltalk/node_modules/$(dirname ${npmName})"
ln -s ${storePath} "$out/lib/smalltalk/node_modules/${npmName}"
'') siblingLinks
)}

# package.json declares `st` and `smalltalk`; both reach the same
# cli.ts. `_ST_INVOKED_AS` is what bin/st exports so help banners and
# the MCP server report the name the user actually typed.
# TODO(rust): a compiled binary replaces these strip-types shims.
mkdir -p $out/bin
for _binName in st smalltalk; do
cat > $out/bin/$_binName <<EOF
#!${pkgs.runtimeShell}
export _ST_INVOKED_AS=$_binName
exec ${pkgs.nodejs_24}/bin/node --experimental-strip-types \\
$out/lib/smalltalk/src/cli.ts "\$@"
EOF
chmod +x $out/bin/$_binName
done

runHook postInstall
'';

# Completions are generated by the binary we just built rather than
# committed, so they cannot drift from the command tree in
# src/commands/completions.ts. The CLI touches $HOME on startup, which
# is not writable in the sandbox.
postInstall = ''
export HOME=$(mktemp -d)
${pkgs.lib.concatMapStringsSep "\n" (shell: ''
$out/bin/st completions ${shell} > completions-${shell}
'') completionShells}

installShellCompletion --cmd st \
--bash completions-bash \
--zsh completions-zsh \
--fish completions-fish
'';

meta = {
description = "File-folder coordination CLI and embeddable TypeScript API";
homepage = "https://github.com/compoundingtech/smalltalk";
license = pkgs.lib.licenses.mit;
mainProgram = "st";
};
};
# Base for checks that need devDependencies (tsc, vitest), which the
# shipped package deliberately omits.
#
# Two things make this awkward enough to be worth explaining. First,
# npm cannot resolve the `file:../pty` devDependency from a store
# symlink: it chmods the linked `bin/pty`, which is read-only in the
# store, and fails EPERM. So the sibling is *copied* to the exact
# relative path the lockfile names and made writable. Second,
# `--ignore-scripts` keeps node-pty's native build (`hasInstallScript`)
# out of the sandbox; nothing gated here needs the native addon.
#
# These run the check in the build dir and `touch $out` rather than
# installing anything, so the store-external `../pty` symlink npm
# leaves in node_modules never has to survive into the output.
mkDevCheck =
name: script:
smalltalk.overrideAttrs (old: {
pname = "smalltalk-${name}";
npmInstallFlags = [ ];
npmFlags = [ "--ignore-scripts" ];
postPatch = ''
cp -r ${pty.packages.${system}.default}/lib/pty ../pty
chmod -R u+w ../pty
'';
installPhase = ''
runHook preInstall
export HOME=$(mktemp -d)
${script}
touch $out
runHook postInstall
'';
postInstall = "";
# Nothing is installed, so the fixup hooks (patchelf,
# noBrokenSymlinks) have nothing useful to do.
dontFixup = true;
});
in
{
packages.smalltalk = smalltalk;
packages.default = smalltalk;

checks.help = pkgs.runCommand "smalltalk-help-${version}" { } ''
export HOME=$(mktemp -d)
${smalltalk}/bin/st --help > /dev/null
touch $out
'';

# Guards the completions contract: every installed shell still gets a
# non-empty script, and fish in particular still binds to `st` (the name
# the installed st.fish file claims).
checks.completions = pkgs.runCommand "smalltalk-completions-${version}" { } ''
export HOME=$(mktemp -d)
${pkgs.lib.concatMapStringsSep "\n" (shell: ''
${smalltalk}/bin/st completions ${shell} | grep -q . \
|| { echo "empty ${shell} completions" >&2; exit 1; }
'') completionShells}

${smalltalk}/bin/st completions fish | grep -q '^complete -c st ' \
|| { echo "fish completions do not bind to \`st\`" >&2; exit 1; }

touch $out
'';

# `npm run build` is typecheck-only (both tsconfigs set `noEmit`), so
# this gates src/ and examples/ against the compiler.
checks.typecheck = mkDevCheck "typecheck" "npm run build";

# The vitest `unit` project is NOT gated here yet. It very nearly works
# (1322/1323 pass in the sandbox), but tests/unit/cli.test.ts asserts
# `st <semver>+<short-sha>`, and the SHA comes from shelling out to
# `git rev-parse` in the source dir — which a hermetic build has no
# `.git` for. That is the build-identity question tracked in #103, not
# something to paper over here. The `integration` project is a separate
# matter: it shells out to rsync and spawns real pty sessions, which the
# build sandbox has no business hosting.

devShells.default = pkgs.mkShell {
packages = [
pkgs.nodejs_24
smalltalk
];
};
}
);
}
Loading