Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 37 additions & 17 deletions bin/sputnik
Original file line number Diff line number Diff line change
Expand Up @@ -33,43 +33,63 @@ if (\Phar::running() !== '') {
use Sputnik\Kernel;
use Symfony\Component\Console\Output\ConsoleOutput;

// Determine working directory
$workingDir = getcwd();
// Determine working directory. Null until --working-dir says otherwise, because
// the default is the project directory, which is not known yet.
$workingDirOption = null;

// Check for --working-dir option
$args = $_SERVER['argv'];
foreach ($args as $i => $arg) {
if (str_starts_with($arg, '--working-dir=')) {
$workingDir = substr($arg, 14);
$workingDirOption = substr($arg, 14);
unset($args[$i]);
$_SERVER['argv'] = array_values($args);
break;
}
if ($arg === '--working-dir' && isset($args[$i + 1])) {
$workingDir = $args[$i + 1];
$workingDirOption = $args[$i + 1];
unset($args[$i], $args[$i + 1]);
$_SERVER['argv'] = array_values($args);
break;
}
}

// Make the project root the working directory of the process, so a task doing
// its own file I/O sees the same place its commands run in. Until now exec() ran
// in the project root while file_exists() in the task resolved against the
// caller's cwd, which is why a wrapper had to cd before calling the binary.
// Two directories, one question each.
//
// Resolving to an absolute path first is not cosmetic: a relative
// --working-dir would otherwise be looked up inside itself after the chdir.
// It also keeps the container cache key stable, which includes this path.
if ($workingDir === false || !is_dir($workingDir)) {
fwrite(\STDERR, \sprintf(
"Working directory does not exist: %s\n",
$workingDir === false ? '(could not be determined)' : $workingDir,
));
// The project directory is where the config lives, and with it the container
// cache and the persisted context. It is searched for upwards, the way git and
// composer find their root, so a call from a subdirectory works and nothing is
// written beside the caller. No config anywhere above means no project - then
// only the built-in init has anything to do.
//
// The working directory is where tasks run: the cwd of exec() and shell(), and
// what relative file access in a task resolves against. It defaults to the
// project directory and only --working-dir moves it.
//
// Resolving to an absolute path before the chdir is not cosmetic: a relative
// --working-dir would otherwise be looked up inside itself, and the container
// cache key contains this path.
$cwd = getcwd();

if ($cwd === false) {
fwrite(\STDERR, "Could not determine the current directory\n");

exit(1);
}

if ($workingDirOption !== null && !is_dir($workingDirOption)) {
fwrite(\STDERR, 'Working directory does not exist: ' . $workingDirOption . "\n");

exit(1);
}

// If --working-dir points somewhere with no project of its own, the project you
// are standing in is kept: running a task in an unrelated directory must not take
// the tasks away. Where both have one, the explicit argument wins.
$projectDir = $workingDirOption === null
? Sputnik\Support\ProjectLocator::locate($cwd)
: Sputnik\Support\ProjectLocator::locate($workingDirOption) ?? Sputnik\Support\ProjectLocator::locate($cwd);
$workingDir = $workingDirOption ?? $projectDir ?? $cwd;
$resolvedWorkingDir = realpath($workingDir);

if ($resolvedWorkingDir === false || !@chdir($resolvedWorkingDir)) {
Expand Down Expand Up @@ -103,7 +123,7 @@ $output = new ConsoleOutput();

// Bootstrap and run
try {
$kernel = new Kernel(workingDir: $workingDir, contextName: $contextOverride);
$kernel = new Kernel(workingDir: $workingDir, contextName: $contextOverride, projectDir: $projectDir);
$output = new Sputnik\Secret\RedactingConsoleOutput($output, $kernel->getSecretRedactor());

// Everything that writes shares this destination: tasks, the commands, and
Expand Down
27 changes: 23 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,29 @@ sputnik --working-dir /var/www/myproject deploy
sputnik --working-dir=/var/www/myproject deploy
```

Sputnik enters that directory, so it is the working directory of the process:
config files, task directories and templates resolve against it, commands run in
it, and a task's own file access -- `file_exists('.ddev/config.yaml')`,
`file_get_contents('dev-ops/config.yaml')` -- sees the same place.
Change where tasks run -- the cwd of `exec()` and `shell()`, and what relative
file access in a task resolves against. Sputnik enters that directory, so
`file_exists('.env')` in a task and the commands it runs see the same place.

It does **not** move the project by itself. The config, the persisted context and
the container cache stay in the
[project directory](project-structure.md#the-project-directory), which is found
by searching upwards. Without this option, tasks run in the project directory.

```bash
sputnik --working-dir=frontend npm:ci # runs in frontend/, state stays at the root
```

Which project applies is decided by what sits at or above the directory you name:

| `--working-dir` points at | Project used |
|---|---|
| a subdirectory of your project | yours -- the search walks up into it |
| a directory with no config above it | yours -- running somewhere unrelated does not take your tasks away |
| a different project | **that** one, with its own tasks and its own state |

The last row is the rule in short: naming a directory behaves as if you had `cd`'d
there, and only a directory with no project of its own leaves you with yours.

A relative path is resolved against the directory you called from. If the
directory does not exist, Sputnik says so and stops.
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ Sputnik is configured using [NEON](https://ne-on.org/) files located in the proj

Both files are automatically loaded and deep-merged. Nested keys are merged recursively, scalar values are replaced. Either file can exist on its own.

Whichever of them exists marks the **project directory**, which Sputnik looks for
upwards from the current directory. Paths declared in the config -- task
directories, template sources and targets -- resolve against it, not against
wherever you happened to run the command from.

See [Project Structure](project-structure.md) for details on file locations and `.gitignore` recommendations.

---
Expand Down
30 changes: 29 additions & 1 deletion docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,39 @@ Local overrides. Gitignored. Values are deep-merged on top of `.sputnik.dist.neo

Either file can exist on its own. If both exist, they are merged. If neither exists, Sputnik starts with an empty configuration (only built-in commands available).

## The Project Directory

The project is the directory holding `.sputnik.dist.neon` (or `.sputnik.neon`).
Sputnik searches for it **upwards** from where you are, the way `git` and
`composer` find their root, so a command works from anywhere inside the project:

```bash
cd htdocs/web/sites/default
sputnik deploy # same project, same state, same place the commands run
```

Everything project-local lives there and only there: the config, the compiled
container and the persisted context. Outside a project -- no config in any parent
directory -- there is nothing to persist, and Sputnik writes nothing beside you:

```bash
cd /tmp/somewhere
sputnik --version # leaves the directory exactly as it was
sputnik init # this is what you came for
```

Tasks run in the project directory by default. `--working-dir` moves **that** and
nothing else: the project keeps its state where it is.

```bash
sputnik --working-dir=frontend npm:ci # runs in frontend/, state stays at the root
```

## Runtime Directory

### `.sputnik/`

Auto-created on first run. Contains:
Created in the project directory on first run. Contains:

- **`state.json`** -- stores the currently active context name. Updated by `context:switch`.
- **`cache/`** -- compiled Nette DI container classes. Automatically invalidated when task files change, configuration changes, or the Sputnik version changes.
Expand Down
36 changes: 27 additions & 9 deletions src/Context/ContextManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ final class ContextManager

public function __construct(
private readonly Configuration $config,
private readonly string $workingDir,
/**
* The project directory, or null when there is no project - a context
* cannot be remembered for something that does not exist, and writing
* it next to the caller is how stray .sputnik directories appeared.
*/
private readonly ?string $projectDir,
) {
}

Expand Down Expand Up @@ -120,24 +125,26 @@ public function getContextDescription(string $contextName): ?string
/**
* Get the state directory path.
*/
public function getStateDir(): string
public function getStateDir(): ?string
{
return $this->workingDir . '/' . self::STATE_DIR;
return $this->projectDir === null ? null : $this->projectDir . '/' . self::STATE_DIR;
}

/**
* Get the state file path.
*/
public function getStateFilePath(): string
public function getStateFilePath(): ?string
{
return $this->getStateDir() . '/' . self::STATE_FILE;
$dir = $this->getStateDir();

return $dir === null ? null : $dir . '/' . self::STATE_FILE;
}

private function loadPersistedContext(): ?string
{
$path = $this->getStateFilePath();

if (file_exists($path)) {
if ($path !== null && file_exists($path)) {
$content = file_get_contents($path);
if ($content === false) {
return null;
Expand All @@ -156,7 +163,13 @@ private function loadPersistedContext(): ?string

private function migrateOldStateFile(): ?string
{
$oldPath = $this->getStateDir() . '/context';
$dir = $this->getStateDir();

if ($dir === null) {
return null;
}

$oldPath = $dir . '/context';

if (!file_exists($oldPath)) {
return null;
Expand All @@ -182,6 +195,10 @@ private function persistContext(string $contextName): void
{
$dir = $this->getStateDir();

if ($dir === null) {
return;
}

if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new SputnikRuntimeException('Could not create state directory: ' . $dir);
}
Expand All @@ -192,13 +209,14 @@ private function persistContext(string $contextName): void
'version' => 1,
];

$path = $dir . '/' . self::STATE_FILE;
$result = file_put_contents(
$this->getStateFilePath(),
$path,
json_encode($state, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) . "\n",
);

if ($result === false) {
throw new SputnikRuntimeException('Could not write state file: ' . $this->getStateFilePath());
throw new SputnikRuntimeException('Could not write state file: ' . $path);
}
}
}
25 changes: 22 additions & 3 deletions src/DependencyInjection/ContainerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ final class ContainerFactory

public function __construct(
private readonly Configuration $config,
private readonly ?string $projectDir,
private readonly string $workingDir,
private readonly string $contextName,
private readonly bool $debugMode = false,
Expand All @@ -26,7 +27,7 @@ public function __construct(

public function create(): Container
{
$cacheDir = $this->workingDir . '/' . self::CACHE_DIR;
$cacheDir = $this->cacheDir();

if (!is_dir($cacheDir) && !mkdir($cacheDir, 0755, true) && !is_dir($cacheDir)) {
throw new SputnikRuntimeException('Could not create cache directory: ' . $cacheDir);
Expand All @@ -43,6 +44,7 @@ function (Compiler $compiler): ?string {
[
$this->config->all(),
$this->contextName,
$this->projectDir,
$this->workingDir,
$this->getTaskFilesHash(),
Application::VERSION,
Expand All @@ -53,6 +55,22 @@ function (Compiler $compiler): ?string {
return new $containerClass();
}

/**
* The compiled container belongs to the project. Without one there is
* nothing to keep between runs, and writing beside the caller is how a
* .sputnik directory appeared in every directory the binary was invoked
* from - so it goes to the system temp directory instead, keyed like any
* other build of this container.
*/
private function cacheDir(): string
{
if ($this->projectDir !== null) {
return $this->projectDir . '/' . self::CACHE_DIR;
}

return sys_get_temp_dir() . '/sputnik-cache-' . md5(Application::VERSION);
}

/**
* Fingerprint Sputnik's own sources, so any change to the service graph or
* to a wired class invalidates the cache even when Application::VERSION is
Expand All @@ -71,7 +89,7 @@ private function getServiceDefinitionsFingerprint(): string
private function getTaskFilesHash(): string
{
$files = [];
$directories = $this->config->getTaskDirectories($this->workingDir);
$directories = $this->config->getTaskDirectories($this->projectDir ?? $this->workingDir);

foreach ($directories as $directory) {
if (!is_dir($directory)) {
Expand Down Expand Up @@ -99,13 +117,14 @@ private function configureCompiler(Compiler $compiler): void
// Add parameters
$compiler->addConfig([
'parameters' => [
'projectDir' => $this->projectDir,
'workingDir' => $this->workingDir,
'contextName' => $this->contextName,
'debug' => $this->debugMode,
],
]);

// Add extensions
$compiler->addExtension('sputnik', new SputnikExtension($this->config, $this->workingDir));
$compiler->addExtension('sputnik', new SputnikExtension($this->config, $this->projectDir ?? $this->workingDir, $this->workingDir));
}
}
13 changes: 11 additions & 2 deletions src/DependencyInjection/SputnikExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ final class SputnikExtension extends CompilerExtension

public function __construct(
private readonly Configuration $sputnikConfig,
/**
* Where config-declared paths resolve: task directories, templates.
*/
private readonly string $projectDir,
/**
* Where tasks run.
*/
private readonly string $workingDir,
) {
}
Expand Down Expand Up @@ -82,7 +89,9 @@ public function loadConfiguration(): void
$builder->addDefinition($this->prefix('contextManager'))
->setFactory(ContextManager::class, [
'config' => $this->prefix('@config'),
'workingDir' => $params['workingDir'],
// The persisted context belongs to the project, not to wherever
// the binary was invoked.
'projectDir' => $params['projectDir'],
])
->setAutowired(true);

Expand Down Expand Up @@ -213,7 +222,7 @@ public function beforeCompile(): void
private function getTaskDiscovery(): TaskDiscovery
{
if (!$this->taskDiscovery instanceof TaskDiscovery) {
$taskDirs = $this->sputnikConfig->getTaskDirectories($this->workingDir);
$taskDirs = $this->sputnikConfig->getTaskDirectories($this->projectDir);
$taskClasses = $this->sputnikConfig->getTaskClasses();
$this->taskDiscovery = new TaskDiscovery($taskDirs, $taskClasses);
}
Expand Down
Loading