@kubilaycakmak/projectfreeze 0.0.0-stage → 0.1.0-dev.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -2
- package/bin/projectfreeze.js +4 -0
- package/docs/interfaces.md +98 -0
- package/docs/operations.md +42 -0
- package/examples/demo.js +72 -0
- package/examples/stream-test.mjs +71 -0
- package/examples/workload.js +29 -0
- package/helpers/.gitkeep +0 -0
- package/helpers/projectfreeze-darwin-arm64 +0 -0
- package/helpers/projectfreeze-darwin-arm64.sha256 +1 -0
- package/helpers/projectfreeze-darwin-x64 +0 -0
- package/helpers/projectfreeze-darwin-x64.sha256 +1 -0
- package/helpers/projectfreeze-linux-arm64 +0 -0
- package/helpers/projectfreeze-linux-arm64.sha256 +1 -0
- package/helpers/projectfreeze-linux-x64 +0 -0
- package/helpers/projectfreeze-linux-x64.sha256 +1 -0
- package/helpers/projectfreeze-win32-arm64.exe +0 -0
- package/helpers/projectfreeze-win32-arm64.exe.sha256 +1 -0
- package/helpers/projectfreeze-win32-x64.exe +0 -0
- package/helpers/projectfreeze-win32-x64.exe.sha256 +1 -0
- package/package.json +17 -4
- package/src/cli.js +135 -0
- package/src/contracts.d.ts +28 -0
- package/src/discovery.js +112 -0
- package/src/engine.js +820 -0
- package/src/errors.js +20 -0
- package/src/integrations.js +195 -0
- package/src/inventory.js +37 -0
- package/src/ipc.js +258 -0
- package/src/line-menu.js +147 -0
- package/src/mcp.js +113 -0
- package/src/platform.js +320 -0
- package/src/recipe.js +69 -0
- package/src/store.js +61 -0
- package/src/supervisors.js +163 -0
- package/src/systemd.js +104 -0
- package/src/tui.js +596 -0
- package/src/windows-supervisors.ps1 +83 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kubilay Cakmak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ProjectFreeze
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ProjectFreeze is a free, open-source tool for discovering and managing developer workloads on your computer. Open the terminal interface with `projectfreeze`, use versioned JSON from scripts, or connect an MCP client over stdio. The daemon runs under your account and keeps ownership and operation history in a private SQLite database.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx @kubilaycakmak/projectfreeze@next
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Run this in an interactive terminal to download and open the current npm prerelease. Confirm the npm installation prompt if it appears. If PowerShell blocks the `npx.ps1` shim, run `npx.cmd @kubilaycakmak/projectfreeze@next`.
|
|
12
|
+
|
|
13
|
+
Freeze, Sleep, and Force Terminate can interrupt running work. Save your work and review the selected processes before using these controls. See [License and liability](#license-and-liability).
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
Use Node.js 24.11.0 or later in the 24.x series, or Node.js 26.x, with `npm` and `npx` on your `PATH`. The npm package includes native helpers for the platforms below. The core CLI runs under your user account without a compiler, administrator or root access, or Docker.
|
|
18
|
+
|
|
19
|
+
| Platform | Tested operating systems and architectures |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Windows | Windows 11 (x64 and ARM64) and Windows Server 2025 (x64) |
|
|
22
|
+
| macOS | macOS 15 (Intel x64 and Apple silicon ARM64) |
|
|
23
|
+
| Linux | Ubuntu 24.04 (x64 and ARM64). The native helper uses glibc and requires a mounted `/proc` filesystem. |
|
|
24
|
+
|
|
25
|
+
Node.js 24 verification covered Windows 11 ARM64, Windows Server 2025, macOS, and Linux. Node.js 26 was tested on Windows and Linux x64, including a manual Windows 11 test. Optional integrations have their own prerequisites, including Docker for Compose and Docker checkpoints, CRIU for native Linux checkpoints, and a user systemd manager for Linux service control. See the [operations guide](docs/operations.md) and [integration requirements](docs/interfaces.md#recipes).
|
|
26
|
+
|
|
27
|
+
## Using the menu
|
|
28
|
+
|
|
29
|
+
Use the arrow keys to select a project, Enter to inspect it, and Tab to switch views. Use `/` to search, `f` to Freeze, `z` for Sleep, `w` for Wake, and `q` to disconnect. Press `?` for the full key reference. ProjectFreeze asks you to review candidate processes before adoption.
|
|
30
|
+
|
|
31
|
+
Append `menu --plain` to the quick-start command for a line-driven fallback. To run individual commands, pass them after the package name.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx @kubilaycakmak/projectfreeze@next discover --json
|
|
35
|
+
npx @kubilaycakmak/projectfreeze@next listeners
|
|
36
|
+
npx @kubilaycakmak/projectfreeze@next inspect PROJECT
|
|
37
|
+
npx @kubilaycakmak/projectfreeze@next freeze PROJECT
|
|
38
|
+
npx @kubilaycakmak/projectfreeze@next resume PROJECT
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Freeze preserves process memory and usually retains bound ports. Sleep shuts down approved resources and requires a durable restart recipe. Wake starts a fresh workload and waits for configured readiness. Checkpoint and restore depend on the eligible local backend and capture scope.
|
|
42
|
+
|
|
43
|
+
The development build adds Ports and Services views and a two-step `k` force-termination command for one owned native project. Build the local helper with `npm run build:native` and open the development menu with `node bin/projectfreeze.js`. Set `PROJECTFREEZE_HOME` to a separate absolute directory to test alongside an installed copy.
|
|
44
|
+
|
|
45
|
+
- [Commands, recipes, and MCP](docs/interfaces.md)
|
|
46
|
+
- [Architecture, security, and recovery](docs/operations.md)
|
|
47
|
+
- [Development, verification, and packaging](CONTRIBUTING.md)
|
|
48
|
+
|
|
49
|
+
## License and liability
|
|
50
|
+
|
|
51
|
+
ProjectFreeze is provided “as is” under the MIT License, without warranties. Use it at your own risk. The authors and copyright holders disclaim liability for claims, damages, or other losses arising from the software or its use to the extent permitted by applicable law. See the [MIT License](LICENSE) for the full terms.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Commands, recipes, and MCP
|
|
2
|
+
|
|
3
|
+
With no arguments, a terminal session opens the interactive interface. A redirected session produces a project listing. `--json` uses a single object with `schemaVersion: 1`, `ok`, and either `data` or `error`. A failed operation can carry its durable journal as `data` with `ok: false`.
|
|
4
|
+
|
|
5
|
+
| Command | Behavior |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `list`, `discover`, `inspect PROJECT` | Report registered projects, candidate projects, and scoped observations. |
|
|
8
|
+
| `menu`, `menu --plain` | Open the live keyboard interface or its line-driven fallback. |
|
|
9
|
+
| `--version` | Print the installed package version. The live menu shows the same version in its header. |
|
|
10
|
+
| `listeners` | List TCP endpoints and PIDs for locally observable processes. Unassigned entries are read-only. |
|
|
11
|
+
| `ports` | List local TCP listeners and bound UDP sockets. Reports a PID when ownership is available. |
|
|
12
|
+
| `services` | List running services visible to the local Windows SCM, systemd system and user managers, or the current macOS launchd domain. |
|
|
13
|
+
| `adopt [PROJECT] --cwd DIR --member PID@BIRTH@BOOT --confirm` | Approve exact identities from current discovery. An optional registered project ID sets the destination. Repeat `--member` and `--exclude` as needed. `--recipe FILE` supplies restart settings. |
|
|
14
|
+
| `run --cwd DIR -- EXECUTABLE ARG...` | Launch literal arguments. Use a fully qualified executable or an absolute PATH entry. |
|
|
15
|
+
| `run --recipe FILE` | Launch a reviewed JSON recipe. |
|
|
16
|
+
| `freeze PROJECT...`, `resume PROJECT...` | Suspend owned execution or release recorded suspensions. |
|
|
17
|
+
| `sleep PROJECT...`, `wake PROJECT...` | Stop reviewed resources or start from a durable recipe. |
|
|
18
|
+
| `checkpoint PROJECT --backend criu\|docker` | Capture supported local state. |
|
|
19
|
+
| `restore PROJECT --checkpoint ID` | Restore a verified capture while the workload is stopped. |
|
|
20
|
+
| `reconcile [PROJECT...]` | Compare stored state against current resources. |
|
|
21
|
+
| `history`, `operation ID`, `cancel ID` | Read operations and request cancellation. |
|
|
22
|
+
| `daemon start\|status\|stop\|foreground` | Control daemon lifecycle. |
|
|
23
|
+
| `mcp` | Run the local MCP stdio server. |
|
|
24
|
+
|
|
25
|
+
Mutating commands wait for an operation result unless `--detach` is given. The operation has a UUID, action, project list, status, UTC timestamps, pending step, and per-resource outcomes. Closing the terminal or interrupting the CLI disconnects the client. `cancel ID` requests cancellation before the next resource boundary. Completed effects and in-flight native calls can survive cancellation.
|
|
26
|
+
|
|
27
|
+
The live menu rescans on entry and periodically. Tab or Left/Right switches between Projects, Ports, Services, and History. Arrow keys and Page Up/Down move the selection or scroll details. Enter opens the selection or confirms a preview. Escape goes back or cancels. Search with `/` and use Space to select multiple projects. Ports and Services are read-only views. Ports reads local OS tables without probing the network. Linux Ports shows the current network namespace. Socket ownership or service manager access can depend on the current account. Discovered processes need explicit ownership review before control.
|
|
28
|
+
|
|
29
|
+
Press `?` to open the grouped shortcut reference. In a narrow terminal, scroll with the arrow keys or Page Up/Down. Home and End jump to the top and bottom. Press `?` again or Esc to return to the current view. When the CLI entry point is observable, Projects omits ProjectFreeze CLI processes from other installations. Their listening sockets remain visible in Ports.
|
|
30
|
+
|
|
31
|
+
Project details show the ownership confidence, declared dependencies, registered services and sharing policies, and recent incomplete operations. Press Space in project details to collapse or expand the process tree. From the project list, Space toggles selection instead.
|
|
32
|
+
|
|
33
|
+
Select one owned native project and press `k` to review force termination. The preview lists the exact process IDs. Press Enter to continue, then type `KILL` and press Enter again to authorize SIGKILL on Linux and macOS or TerminateProcess on Windows. Esc cancels either prompt. Force termination skips graceful shutdown and suppresses automatic restart. Use `w` to start again when a restart recipe is available. ProjectFreeze rejects a changed process set and directs projects with registered services or active dependents to their service shutdown workflow.
|
|
34
|
+
|
|
35
|
+
## Test Sleep and Wake
|
|
36
|
+
|
|
37
|
+
Use the included `examples/stream-test.mjs` to create a launch recipe and see the process identity at the client. From a dedicated test directory, run `node /path/to/stream-test.mjs --recipe stream.recipe.json 8788`. Press `l`, enter the recipe path, and confirm with Enter. In another terminal run `curl -N http://127.0.0.1:8788/stream` (`curl.exe` on Windows). Select the project, press `z`, and confirm. The stream ends and the listener disappears. Press `w`, confirm, and reconnect with curl to see the new PID. The fixture includes a graceful shutdown command. Choose an unused port if `8788` is occupied.
|
|
38
|
+
|
|
39
|
+
Run `node examples/demo.js` from the package directory for an isolated loopback demonstration of discovery, adoption, freeze, resume, sleep, and wake. On a Linux host where CRIU works with the current user's permissions, run `node examples/demo.js --checkpoint` to capture a disposable workload, stop it, and restore it. Both demos use temporary state and clean up their own workloads.
|
|
40
|
+
|
|
41
|
+
| Exit code | Meaning |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| 0 | Successful command or accepted detached operation |
|
|
44
|
+
| 1 | Internal failure |
|
|
45
|
+
| 2 | Invalid input |
|
|
46
|
+
| 3 | Resource or identity missing |
|
|
47
|
+
| 4 | Permission or policy denied |
|
|
48
|
+
| 5 | Capability unavailable on this host or capture scope |
|
|
49
|
+
| 6 | Operation conflict |
|
|
50
|
+
| 7 | Partial or interrupted operation |
|
|
51
|
+
| 8 | Cancelled |
|
|
52
|
+
| 9 | Prerequisite or daemon unavailable |
|
|
53
|
+
|
|
54
|
+
## Recipes
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"cwd": "/absolute/project/path",
|
|
59
|
+
"instance": "web",
|
|
60
|
+
"argv": ["node", "server.js"],
|
|
61
|
+
"env": ["APP_DATABASE_URL"],
|
|
62
|
+
"dependencies": [],
|
|
63
|
+
"ready": { "address": "127.0.0.1", "port": 8080 },
|
|
64
|
+
"stop": ["node", "shutdown.js"],
|
|
65
|
+
"timeout": 10000,
|
|
66
|
+
"restart": "never"
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`argv` and `stop` are literal argument arrays. For shell scripts select the shell explicitly and supply its arguments. Windows recipes can call `cmd.exe` or `powershell.exe` explicitly to interpret shell commands. Keep secret values out of literal arguments and recipe files. `env` names refer to values in the daemon environment at launch. Start or restart the daemon from an environment containing the required values. The daemon passes a small platform environment and the listed references to workloads. Output streams of managed launches are discarded to keep secret-bearing logs out of state. Configure application logging at the workload boundary.
|
|
71
|
+
|
|
72
|
+
Set `instance` to give a workload its own durable project ID inside a shared directory. For example, `web` and `worker` can use separate recipes in one checkout. Recipes without an instance keep the directory's original ID. To adopt into a named project for the first time, include its instance in the reviewed recipe. To add processes to an existing project, pass its ID to `adopt`, or open that project and press `a`. Approve only the identities that belong to that workload.
|
|
73
|
+
|
|
74
|
+
`ready` connects to a loopback TCP port observed on an owned process. `stop` should implement application-specific graceful shutdown. Without `stop`, POSIX uses SIGTERM. Windows requires `stop` or specifically approved `--force` termination. `restart: "on-failure"` permits up to three retries while the launch supervisor remains available. Linux may specify `cgroup` as an existing delegated cgroup v2 parent directory. `containers` entries name a Compose `project`, `service`, and explicit `shared` boolean. Exclusive services must be existing containers with matching Compose labels and restart policy `no`.
|
|
75
|
+
|
|
76
|
+
On Linux, set `systemd` to an array such as `[{"unit":"worker.service","shared":false},{"unit":"worker.timer","shared":false}]`. Link the reviewed unit files into your user manager with `systemctl --user link /absolute/project/worker.service /absolute/project/worker.timer`. Keep unit files within the project, restrict write access to their owner, set the service `WorkingDirectory` to the project, and configure `SendSIGKILL=no`. The adapter controls only the exact loaded units. It rejects drop-ins and activation or lifecycle dependencies outside the exclusive reviewed set. Active timers in that set are stopped before service suspension or shutdown and restarted after recovery.
|
|
77
|
+
|
|
78
|
+
Use `supervisors` to associate existing platform resources with a project recipe. Set `shared` to `true` for observation-only resources. Cross-project references are also treated as shared.
|
|
79
|
+
|
|
80
|
+
| Platform | Example entry | Verified scope |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| macOS | `{"kind":"launchd","name":"com.example.worker","plist":"/absolute/project/worker.plist","shared":false}` | A loaded GUI user LaunchAgent with an owner-controlled project plist, matching executable and working directory, one observable process, and no automatic activation. |
|
|
83
|
+
| Windows | `{"kind":"scm","name":"ExampleWorker","shared":false}` | An existing manual-start, own-process service with an executable in the project, no dependencies, dependents, triggers, or recovery actions, and a current-user or LocalService account. SCM decides which controls the service supports. |
|
|
84
|
+
| Windows | `{"kind":"task","name":"\\ExampleTimer","shared":false}` | An existing low-privilege task for the current user with a single project executable and matching working directory. Running or queued tasks delay freeze and sleep. Only idle activation is disabled and restored. |
|
|
85
|
+
|
|
86
|
+
Register and authorize platform resources through the operating system before giving ProjectFreeze their definitions. Worker output, arguments, and credentials remain in the owning supervisor. ProjectFreeze pins reviewed definitions and rejects changed fingerprints. PowerShell on Windows executes the installed adapter with the existing execution policy.
|
|
87
|
+
|
|
88
|
+
## MCP
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"projectfreeze": { "command": "projectfreeze", "args": ["mcp"] }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The stdio server implements initialization, `tools/list`, `tools/call`, cancellation, and progress notifications for operations requested with a progress token. Tool schemas are returned by `tools/list`. The `ports` and `services` tools expose the same read-only inventory as their CLI commands. Tool output uses the CLI data shapes and the same daemon ownership checks. The MCP host grants local permissions to its process and must use its approval UI when requesting ambiguous adoption, launching code, or forcing shutdown. See the [MCP stdio transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Architecture, security, and recovery
|
|
2
|
+
|
|
3
|
+
```mermaid
|
|
4
|
+
flowchart LR
|
|
5
|
+
TUI[Terminal UI] --> IPC[Authenticated local IPC]
|
|
6
|
+
CLI[CLI and JSON] --> IPC
|
|
7
|
+
MCP[MCP stdio] --> IPC
|
|
8
|
+
IPC --> Engine[Operation engine]
|
|
9
|
+
Engine <--> State[(Private SQLite state)]
|
|
10
|
+
Engine --> Native[Native process helper]
|
|
11
|
+
Engine --> Docker[Local Docker engine]
|
|
12
|
+
Engine --> CRIU[Linux CRIU]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The daemon serializes mutations and journals pending effects and outcomes. POSIX uses a socket in a user-owned 0700 directory or loopback when the state path exceeds the socket system limit. Windows uses loopback and an endpoint file in a directory with a restricted user and SYSTEM ACL. Both transports authenticate each connection and its request and response using fresh nonces and HMAC. There is no HTTP management surface. Same-account programs that can read state are inside the trust boundary. The daemon performs no elevation, firewall edits, service installation, or workload downloads. A user-approved command executes with that user's permissions.
|
|
16
|
+
|
|
17
|
+
## Scope and metrics
|
|
18
|
+
|
|
19
|
+
Candidates combine process metadata, manifest paths, executable metadata, ancestry, and TCP listeners where observable. They are observations. Adoption records exact process identities after review. Native launches use a staged handshake to record identity before allowing application execution. Linux opens PID file descriptors for process control. macOS rechecks the process creation identity and uses a task audit token. Windows checks a process handle and creation time and groups managed descendants in a Job Object. Read the primary references for [Linux pidfds](https://man7.org/linux/man-pages/man2/pidfd_send_signal.2.html), [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html), [macOS process interfaces](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/proc_info.h), and [Windows Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects).
|
|
20
|
+
|
|
21
|
+
CPU is sampled as percent of one logical core. Resident-memory figures sum observed process RSS and can count shared pages more than once. A project dependency is listed by reference. Compose services referenced by multiple projects are treated as shared. Listening port results describe the authorized TCP scan. An absent result can also indicate an out-of-scope listener.
|
|
22
|
+
|
|
23
|
+
One canonical directory can hold several durable projects with named recipe instances. Discovery groups unclaimed processes by directory and requires individual review before assigning them to a project. Nearby monorepo manifests can produce separate discovery groups. Detached or reparented processes remain candidates when an executable, script path, or observable working directory still ties them to a project. They require individual review when they escape native containment. The current process scan provides no reliable historical command reconstruction. The systemd and native supervisor adapters coordinate only registered resources with verified definitions and permissions.
|
|
24
|
+
|
|
25
|
+
## Recovering a workload
|
|
26
|
+
|
|
27
|
+
1. Run `projectfreeze history` or `projectfreeze operation ID` to see the recorded intent and completed effects.
|
|
28
|
+
2. Run `projectfreeze inspect PROJECT` and `projectfreeze reconcile PROJECT` to verify identities and current state.
|
|
29
|
+
3. If a freeze completed partially, `projectfreeze resume PROJECT` releases recorded ProjectFreeze suspensions. A process that was already suspended is left for its original owner. After an interruption during an unrecorded effect, inspect the pending step before taking manual action.
|
|
30
|
+
4. If shutdown was partial, inspect which resources remain and retry the reviewed action. Verify that any stopped dependency is safe to restart before wake. A successful wake checks TCP readiness when configured. Application-level recovery may also require credential, connection, timer, and pending I/O checks.
|
|
31
|
+
|
|
32
|
+
Daemon restart preserves workloads and state. Native launch supervisors persist exit receipts independently of the daemon. The `on-failure` policy consumes a recorded nonzero exit on the same boot and stops after three restart attempts. A missing receipt or a changed boot identity requires review. A package upgrade should stop the daemon, install the verified package, and start it against the same state directory. Keep recoverable metadata and the old package until reconciliation succeeds. Windows launches use a hash-addressed helper copy inside private state so an active launch supervisor cannot lock the installed npm package during removal.
|
|
33
|
+
|
|
34
|
+
When upgrading on Windows from `0.1.0-dev.0`, install the new tarball in a separate npm prefix while old workloads are alive. The old launcher can hold its installed executable open. Stop the old daemon, invoke the new installation with the same `PROJECTFREEZE_HOME`, and verify that `inspect` sees the original workload. Remove the old installation after its workloads exit and the launcher releases its executable.
|
|
35
|
+
|
|
36
|
+
## Checkpoints
|
|
37
|
+
|
|
38
|
+
If the daemon stops during restore, run `projectfreeze reconcile PROJECT`. It clears the interruption only when it can verify the journaled restored process or original running container, or after the host boot changes. If it cannot identify the outcome, the project stays partial and rejects lifecycle changes. Review the restore journal and backend logs before intervening at the OS level. A host restart clears the old-boot effect and allows a cold wake. Keep the checkpoint files for diagnosis.
|
|
39
|
+
|
|
40
|
+
The native adapter invokes [Linux CRIU](https://github.com/checkpoint-restore/criu) with `check`, `dump`, and `restore`. Docker delegates to the local [Docker checkpoint CLI](https://docs.docker.com/reference/cli/docker/checkpoint/). Docker images stay under the original container's engine-managed checkpoint directory, which must be readable for integrity checks. The capture can contain process memory and secrets. The manifest records image hashes, backend version, scope, and host prerequisites. Restore requires the original project and a compatible backend and host. The current implementation restricts restore to the original boot. Successful capture depends on CRIU permissions, kernel support, capture scope, and backing files. A checkpoint does not roll back external side effects or guarantee surviving network sessions. Windows and macOS native operation use freeze/resume and sleep/wake. The candidate provides no VM snapshot integration.
|
|
41
|
+
|
|
42
|
+
The Docker checkpoint adapter requires a workload already configured for host networking. Isolated network namespaces hit an [upstream restore failure](https://github.com/moby/moby/issues/50750) on the tested engine. Keep the existing isolation and use pause/unpause or stop/start for those containers. ProjectFreeze never changes a container's network mode.
|
package/examples/demo.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { copyFile, mkdtemp, realpath, writeFile, unlink, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
const cli = fileURLToPath(new URL('../bin/projectfreeze.js', import.meta.url));
|
|
12
|
+
const checkpoint = process.argv[2] === '--checkpoint';
|
|
13
|
+
if (checkpoint && process.platform !== 'linux') throw new Error('The checkpoint demo requires Linux with a working CRIU installation.');
|
|
14
|
+
const base = await realpath(await mkdtemp(join(tmpdir(), 'pf-demo-')));
|
|
15
|
+
const worker = join(base, 'workload.mjs');
|
|
16
|
+
const marker = join(base, 'stop');
|
|
17
|
+
const port = await new Promise(resolve => {
|
|
18
|
+
const server = createServer();
|
|
19
|
+
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
|
|
20
|
+
});
|
|
21
|
+
const env = { ...process.env, PROJECTFREEZE_HOME: join(base, 'state'), PF_DEMO_PORT: String(port), PF_DEMO_STOP_FILE: marker };
|
|
22
|
+
const recipe = join(base, 'recipe.json');
|
|
23
|
+
const start = [process.execPath, worker];
|
|
24
|
+
const stop = [process.execPath, '-e', 'require("node:fs").writeFileSync(process.argv[1], "stop")', marker];
|
|
25
|
+
const run = async (...args) => {
|
|
26
|
+
const result = await exec(process.execPath, [cli, '--json', ...args], { env, windowsHide: true }).catch(error => error);
|
|
27
|
+
const reply = JSON.parse(result.stdout);
|
|
28
|
+
if (!reply.ok) throw new Error(JSON.stringify(reply.error ?? reply.data));
|
|
29
|
+
return reply.data;
|
|
30
|
+
};
|
|
31
|
+
let id, external;
|
|
32
|
+
try {
|
|
33
|
+
if (checkpoint) {
|
|
34
|
+
({ projects: [id] } = await run('run', '--cwd', base, '--', '/usr/bin/sleep', '120'));
|
|
35
|
+
const capture = await run('checkpoint', id);
|
|
36
|
+
const image = capture.outcomes.find(row => row.status === 'captured').target;
|
|
37
|
+
console.log(`Checkpoint ${image}`);
|
|
38
|
+
await run('sleep', id);
|
|
39
|
+
console.log(`Restore ${(await run('restore', id, '--checkpoint', image)).status}`);
|
|
40
|
+
console.log(`Restored ${(await run('inspect', id)).processCount} process(es)`);
|
|
41
|
+
} else {
|
|
42
|
+
await copyFile(new URL('./workload.js', import.meta.url), worker);
|
|
43
|
+
await writeFile(join(base, 'package.json'), '{}');
|
|
44
|
+
await writeFile(recipe, JSON.stringify({ cwd: base, instance: 'demo', argv: start, stop, env: ['PF_DEMO_PORT', 'PF_DEMO_STOP_FILE'],
|
|
45
|
+
ready: { address: '127.0.0.1', port } }));
|
|
46
|
+
external = spawn(start[0], start.slice(1), { cwd: base, env, windowsHide: true, stdio: 'ignore' });
|
|
47
|
+
const deadline = Date.now() + 10000;
|
|
48
|
+
while (!(await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(300) }).then(reply => reply.ok, () => false))) {
|
|
49
|
+
if (Date.now() >= deadline || external.exitCode !== null) throw new Error('The demo listener did not start.');
|
|
50
|
+
await delay(150);
|
|
51
|
+
}
|
|
52
|
+
const discovered = (await run('discover')).find(project => project.cwd === base);
|
|
53
|
+
const member = discovered?.processes.find(row => row.pid === external.pid);
|
|
54
|
+
if (!member) throw new Error('The independently launched demo process was not discovered.');
|
|
55
|
+
console.log(`Discovered PID ${member.pid} on 127.0.0.1:${port}`);
|
|
56
|
+
({ projects: [id] } = await run('adopt', '--cwd', base, '--member', `${member.pid}@${member.birth}@${member.boot}`, '--confirm', '--recipe', recipe));
|
|
57
|
+
console.log(`Adopted ${id} ${JSON.stringify((await run('inspect', id)).ports)}`);
|
|
58
|
+
for (const command of ['freeze', 'resume', 'sleep', 'wake', 'sleep']) {
|
|
59
|
+
if (command === 'wake') await unlink(marker);
|
|
60
|
+
console.log(`${command} ${(await run(command, id)).status}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
if (id) {
|
|
65
|
+
await run('resume', id).catch(() => {});
|
|
66
|
+
await run('sleep', id).catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
if (external?.exitCode === null) external.kill();
|
|
69
|
+
await run('daemon', 'stop').catch(() => {});
|
|
70
|
+
await delay(500);
|
|
71
|
+
await rm(base, { recursive: true, force: true, maxRetries: 5 });
|
|
72
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { access, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const host = '127.0.0.1';
|
|
7
|
+
const [mode, path, recipePort] = process.argv.slice(2);
|
|
8
|
+
if (mode === '--stop') {
|
|
9
|
+
await writeFile(path, '', { flag: 'wx' }).catch(error => { if (error.code !== 'EEXIST') throw error; });
|
|
10
|
+
} else if (mode === '--recipe') {
|
|
11
|
+
const file = resolve(path);
|
|
12
|
+
const cwd = dirname(file);
|
|
13
|
+
const port = parsePort(recipePort ?? 8788, 1);
|
|
14
|
+
const script = fileURLToPath(import.meta.url);
|
|
15
|
+
const marker = join(cwd, `.stream-stop-${port}`);
|
|
16
|
+
const recipe = { cwd, argv: [process.execPath, script, String(port), marker], stop: [process.execPath, script, '--stop', marker],
|
|
17
|
+
ready: { address: host, port }, timeout: 12000 };
|
|
18
|
+
await writeFile(file, `${JSON.stringify(recipe, null, 2)}\n`, { flag: 'wx' });
|
|
19
|
+
console.log(file);
|
|
20
|
+
} else {
|
|
21
|
+
await serve(parsePort(mode ?? 8788, 0), path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parsePort(value, minimum) {
|
|
25
|
+
const port = Number(value);
|
|
26
|
+
if (!Number.isInteger(port) || port < minimum || port > 65535) throw new RangeError(`Pass a TCP port between ${minimum} and 65535.`);
|
|
27
|
+
return port;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function serve(port, marker) {
|
|
31
|
+
let count = 0;
|
|
32
|
+
let closing = false;
|
|
33
|
+
const clients = new Set();
|
|
34
|
+
const server = createServer((request, response) => {
|
|
35
|
+
if (request.method !== 'GET' || request.url !== '/stream') {
|
|
36
|
+
response.writeHead(404).end('Use GET /stream.\n');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
40
|
+
response.flushHeaders();
|
|
41
|
+
clients.add(response);
|
|
42
|
+
response.on('close', () => clients.delete(response));
|
|
43
|
+
});
|
|
44
|
+
const timer = setInterval(() => {
|
|
45
|
+
const line = `#${++count} ${new Date().toISOString()} PID=${process.pid}\n`;
|
|
46
|
+
for (const client of clients) if (!client.writableNeedDrain) client.write(line);
|
|
47
|
+
}, 1000);
|
|
48
|
+
timer.unref();
|
|
49
|
+
const watch = marker && setInterval(async () => {
|
|
50
|
+
if (await access(marker).then(() => true, () => false)) close();
|
|
51
|
+
}, 150);
|
|
52
|
+
watch?.unref();
|
|
53
|
+
|
|
54
|
+
server.once('error', error => {
|
|
55
|
+
console.error(`Could not listen on ${host}:${port} (${error.code ?? error.message}).`);
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
});
|
|
58
|
+
server.listen(port, host, () => console.log(`PID=${process.pid} http://${host}:${server.address().port}/stream`));
|
|
59
|
+
function close() {
|
|
60
|
+
if (closing) return;
|
|
61
|
+
closing = true;
|
|
62
|
+
clearInterval(timer);
|
|
63
|
+
clearInterval(watch);
|
|
64
|
+
for (const client of clients) client.end();
|
|
65
|
+
server.close(async () => {
|
|
66
|
+
if (marker) await unlink(marker).catch(error => { if (error.code !== 'ENOENT') process.exitCode = 1; });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
process.once('SIGINT', close);
|
|
70
|
+
process.once('SIGTERM', close);
|
|
71
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const port = Number(process.env.PF_DEMO_PORT ?? 8787);
|
|
5
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Set PF_DEMO_PORT to an integer from 1 to 65535.');
|
|
6
|
+
const server = createServer((request, response) => {
|
|
7
|
+
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
8
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
9
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
10
|
+
const found = ['GET', 'HEAD'].includes(request.method) && request.url === '/health';
|
|
11
|
+
response.writeHead(found ? 200 : 404);
|
|
12
|
+
response.end(found ? 'ok\n' : 'not found\n');
|
|
13
|
+
});
|
|
14
|
+
server.requestTimeout = 10000;
|
|
15
|
+
server.headersTimeout = 5000;
|
|
16
|
+
server.maxHeadersCount = 32;
|
|
17
|
+
server.on('connect', (_request, socket) => socket.destroy());
|
|
18
|
+
server.on('upgrade', (_request, socket) => socket.destroy());
|
|
19
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true });
|
|
20
|
+
|
|
21
|
+
const close = () => server.close(() => process.exit(0));
|
|
22
|
+
process.on('SIGTERM', close);
|
|
23
|
+
process.on('SIGINT', close);
|
|
24
|
+
if (process.env.PF_DEMO_STOP_FILE) {
|
|
25
|
+
const timer = setInterval(async () => {
|
|
26
|
+
if (await stat(process.env.PF_DEMO_STOP_FILE).then(() => true, () => false)) { clearInterval(timer); close(); }
|
|
27
|
+
}, 200);
|
|
28
|
+
timer.unref();
|
|
29
|
+
}
|
package/helpers/.gitkeep
ADDED
|
File without changes
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
b3f0ea865bb15a190943b5991f1dfdf27a3899d45b544da8c4b764918fbccec3 projectfreeze-darwin-arm64
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
a6c4ea1a02966c053d5c120a83795634f9d20a9228e2b36ae022fd44c97201d1 projectfreeze-darwin-x64
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
b0f44b4db7167593a90d893dde2277e4f942ccc1e382b4401586d1c7418a8893 projectfreeze-linux-arm64
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
d68927c3ed60670f3f9c49c941d596c52951f915b127ee6e8f968f0e945c7d1a projectfreeze-linux-x64
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
78454be06120e57c03865edcbf873234d86c05ad1140d800de2717267aa4b765 projectfreeze-win32-arm64.exe
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
f2feb9d1960997a3817081e9127fb59831992dfdfe72b159b9d39b38845abce1 projectfreeze-win32-x64.exe
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubilaycakmak/projectfreeze",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0-dev.5",
|
|
4
|
+
"description": "Local management for developer project execution environments",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "projectfreeze": "bin/projectfreeze.js" },
|
|
7
|
+
"engines": { "node": "^24.11.0 || ^26.0.0" },
|
|
8
|
+
"os": ["linux", "darwin", "win32"],
|
|
9
|
+
"cpu": ["x64", "arm64"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": "github:kubilaycakmak/ProjectFreeze",
|
|
12
|
+
"files": ["bin", "src", "helpers", "docs", "examples"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build:native": "node scripts/build-native.js",
|
|
15
|
+
"test": "node --test test/*.test.js",
|
|
16
|
+
"demo": "node examples/demo.js",
|
|
17
|
+
"check": "node --check bin/projectfreeze.js"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import manifest from '../package.json' with { type: 'json' };
|
|
5
|
+
import { Fault, exitCode, problem } from './errors.js';
|
|
6
|
+
import { daemon, request, waitOperation } from './ipc.js';
|
|
7
|
+
|
|
8
|
+
const usage = `ProjectFreeze
|
|
9
|
+
|
|
10
|
+
projectfreeze Open the interactive terminal
|
|
11
|
+
projectfreeze --version Print the installed version
|
|
12
|
+
projectfreeze menu Open the interactive terminal
|
|
13
|
+
projectfreeze menu --plain Use line input and text output
|
|
14
|
+
projectfreeze list | discover | listeners | ports | services | history
|
|
15
|
+
projectfreeze inspect PROJECT
|
|
16
|
+
projectfreeze adopt [PROJECT] --cwd DIR --member PID@BIRTH@BOOT --confirm [--recipe FILE]
|
|
17
|
+
projectfreeze run --cwd DIR -- EXECUTABLE [ARG ...]
|
|
18
|
+
projectfreeze run --recipe FILE
|
|
19
|
+
projectfreeze freeze | resume | sleep | wake PROJECT [PROJECT ...]
|
|
20
|
+
projectfreeze checkpoint PROJECT [--backend criu|docker]
|
|
21
|
+
projectfreeze restore PROJECT --checkpoint ID
|
|
22
|
+
projectfreeze reconcile [PROJECT ...]
|
|
23
|
+
projectfreeze operation ID | cancel ID
|
|
24
|
+
projectfreeze daemon start|status|stop|foreground
|
|
25
|
+
projectfreeze mcp Start the local stdio server
|
|
26
|
+
|
|
27
|
+
Options: --json, --detach, --cwd, --recipe, --member, --exclude, --confirm, --force.
|
|
28
|
+
Sleep with --force approves forced shutdown of the reviewed exclusive scope.
|
|
29
|
+
Press Ctrl-C to disconnect from a running operation. Use cancel ID to request cancellation.
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const mutations = new Set(['adopt', 'run', 'freeze', 'resume', 'sleep', 'wake', 'checkpoint', 'restore', 'reconcile']);
|
|
33
|
+
const readActions = new Set(['list', 'discover', 'inspect', 'listeners', 'ports', 'services', 'history', 'operation', 'cancel']);
|
|
34
|
+
const terminalText = value => String(value).replace(/[\p{Cc}\p{Cf}]/gu, ' ');
|
|
35
|
+
const opts = {
|
|
36
|
+
json: { type: 'boolean' }, plain: { type: 'boolean' }, detach: { type: 'boolean' }, force: { type: 'boolean' }, confirm: { type: 'boolean' },
|
|
37
|
+
cwd: { type: 'string' }, recipe: { type: 'string' }, member: { type: 'string', multiple: true },
|
|
38
|
+
exclude: { type: 'string', multiple: true }, backend: { type: 'string' }, checkpoint: { type: 'string' },
|
|
39
|
+
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function target(raw) {
|
|
43
|
+
const [pid, birth, boot, extra] = raw.split('@');
|
|
44
|
+
if (extra !== undefined || !pid || !birth || !boot) throw new Fault('USAGE', 'Process identities use PID@BIRTH@BOOT.');
|
|
45
|
+
return { pid: Number(pid), birth, boot };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function format(data) {
|
|
49
|
+
if (Array.isArray(data) && data.every(row => row.cwd)) {
|
|
50
|
+
return data.length ? data.map(row => `${row.id}\t${row.state}\t${row.processCount} process(es)\tTCP ${[...new Set(row.ports.map(p => p.port))].join(', ') || 'none'}\t${row.cwd}`).join('\n') : 'No projects in authorized scope.';
|
|
51
|
+
}
|
|
52
|
+
if (data?.outcomes) return [`${data.action} ${data.id} ${data.status}`, ...data.outcomes.map(row =>
|
|
53
|
+
` ${row.target} ${row.status}${row.message ? ` ${row.message}` : ''}`)].join('\n');
|
|
54
|
+
return JSON.stringify(data, null, 2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function main(args) {
|
|
58
|
+
const separator = args.indexOf('--');
|
|
59
|
+
const flags = separator < 0 ? args : args.slice(0, separator);
|
|
60
|
+
const literal = separator < 0 ? [] : args.slice(separator + 1);
|
|
61
|
+
let json = flags.includes('--json');
|
|
62
|
+
try {
|
|
63
|
+
const { positionals, values } = parseArgs({ args: flags, allowPositionals: true, options: opts });
|
|
64
|
+
json = Boolean(values.json);
|
|
65
|
+
let [action, ...ids] = positionals;
|
|
66
|
+
if (values.help || action === 'help') { process.stdout.write(usage); return; }
|
|
67
|
+
if (values.version) {
|
|
68
|
+
const version = manifest.version;
|
|
69
|
+
process.stdout.write(`${json ? JSON.stringify({ schemaVersion: 1, ok: true, data: { version } }) : version}\n`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (action === 'mcp') { await (await import('./mcp.js')).serve(); return; }
|
|
73
|
+
if (action === '_daemon' || (action === 'daemon' && ids[0] === 'foreground')) { await daemon(); return; }
|
|
74
|
+
const terminal = process.stdin.isTTY && process.stdout.isTTY && process.env.TERM !== 'dumb';
|
|
75
|
+
if (action === 'menu' || (!action && !json && (terminal || values.plain))) {
|
|
76
|
+
if (json || ids.length) throw new Fault('USAGE', 'Run projectfreeze menu with an optional --plain flag.');
|
|
77
|
+
if (terminal && !values.plain) await (await import('./tui.js')).interactive();
|
|
78
|
+
else await (await import('./line-menu.js')).lineMenu();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
action ??= 'list';
|
|
82
|
+
let input = {};
|
|
83
|
+
let auto = true;
|
|
84
|
+
if (action === 'daemon') {
|
|
85
|
+
if (!['start', 'status', 'stop'].includes(ids[0]) || ids.length !== 1) throw new Fault('USAGE', 'Choose daemon start, status, stop, or foreground.');
|
|
86
|
+
action = `daemon-${ids[0]}`;
|
|
87
|
+
auto = false;
|
|
88
|
+
} else if (mutations.has(action)) {
|
|
89
|
+
const cwd = resolve(values.cwd ?? '.');
|
|
90
|
+
let file;
|
|
91
|
+
if (values.recipe) {
|
|
92
|
+
try { file = JSON.parse(await readFile(resolve(values.recipe), 'utf8')); }
|
|
93
|
+
catch { throw new Fault('USAGE', 'The recipe file could not be read as JSON.'); }
|
|
94
|
+
}
|
|
95
|
+
if (action === 'adopt') {
|
|
96
|
+
if (ids.length > 1) throw new Fault('USAGE', 'Choose at most one registered project for adoption.');
|
|
97
|
+
input = { id: ids[0], cwd, recipe: file, confirm: values.confirm, members: values.member?.map(target), exclude: values.exclude?.map(target) };
|
|
98
|
+
}
|
|
99
|
+
else if (action === 'run') input = { recipe: file ? { ...file, cwd: resolve(values.cwd ?? file.cwd ?? '.') } : { cwd, argv: literal } };
|
|
100
|
+
else input = { ids, force: values.force, backend: values.backend, checkpoint: values.checkpoint };
|
|
101
|
+
} else if (readActions.has(action)) {
|
|
102
|
+
if (['inspect', 'operation', 'cancel'].includes(action)) {
|
|
103
|
+
if (ids.length !== 1) throw new Fault('USAGE', 'This command requires one identifier.');
|
|
104
|
+
input = { id: ids[0] };
|
|
105
|
+
} else if (ids.length) throw new Fault('USAGE', 'This command takes no positional arguments.');
|
|
106
|
+
} else throw new Fault('USAGE', 'Unknown command. Run projectfreeze --help.');
|
|
107
|
+
|
|
108
|
+
const reply = await request(action, input, auto);
|
|
109
|
+
let result = reply;
|
|
110
|
+
if (mutations.has(action) && !values.detach) {
|
|
111
|
+
if (!json && process.stderr.isTTY) process.stderr.write(`Operation ${reply.id} started.\n`);
|
|
112
|
+
result = await waitOperation(reply.id, op => {
|
|
113
|
+
if (!json && process.stderr.isTTY && op.status === 'running' && op.pending) process.stderr.write(` ${op.pending}\n`);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const status = result?.status;
|
|
117
|
+
const fault = result?.outcomes?.find(outcome => outcome.status === 'failed');
|
|
118
|
+
const code = status === 'partial' || status === 'interrupted' ? 'PARTIAL' : status === 'cancelled' ? 'CANCELLED' : status === 'failed' ? fault?.code ?? 'INTERNAL' : null;
|
|
119
|
+
const display = () => {
|
|
120
|
+
if (action === 'listeners' || action === 'ports') return result.length ? result.map(row =>
|
|
121
|
+
[`${row.protocol ? `${row.protocol.toUpperCase()} ` : ''}${row.address.includes(':') ? `[${row.address}]` : row.address}:${row.port}`,
|
|
122
|
+
`PID ${row.pid ?? 'unknown'}`, row.name ?? row.image ?? 'unassigned', row.scope].map(terminalText).join('\t')).join('\n') : 'No local endpoints observed.';
|
|
123
|
+
if (action === 'services') return [...result.rows.map(row => [row.manager, row.name, `PID ${row.pid ?? 'unknown'}`, row.description ?? ''].map(terminalText).join('\t')),
|
|
124
|
+
...result.unavailable.map(scope => `${scope} service manager unavailable`)].join('\n') || 'No running services observed.';
|
|
125
|
+
return format(result);
|
|
126
|
+
};
|
|
127
|
+
process.stdout.write(`${json ? JSON.stringify({ schemaVersion: 1, ok: !code, data: result }) : display()}\n`);
|
|
128
|
+
if (code) process.exitCode = exitCode(code);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const detail = error?.code?.startsWith?.('ERR_PARSE_ARGS') ? { code: 'USAGE', message: 'Invalid command-line arguments. Run projectfreeze --help.' } : problem(error);
|
|
131
|
+
const output = json ? JSON.stringify({ schemaVersion: 1, ok: false, error: detail }) : `${detail.code} ${detail.message}`;
|
|
132
|
+
(json ? process.stdout : process.stderr).write(`${output}\n`);
|
|
133
|
+
process.exitCode = exitCode(detail.code);
|
|
134
|
+
}
|
|
135
|
+
}
|