@ivotoby/postgram-cli 1.38.3 → 1.40.0
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/README.md +88 -0
- package/dist/client.js +4 -1
- package/dist/pgm.js +14 -0
- package/dist/tracking/api.js +142 -0
- package/dist/tracking/config.js +181 -0
- package/dist/tracking/feature-commands.js +298 -0
- package/dist/tracking/feature-sync.js +151 -0
- package/dist/tracking/here.js +280 -0
- package/dist/tracking/hooks.js +375 -0
- package/dist/tracking/local.js +175 -0
- package/dist/tracking/resume.js +43 -0
- package/dist/tracking/scan-discovery.js +295 -0
- package/dist/tracking/scan.js +579 -0
- package/dist/tracking/types.js +14 -0
- package/dist/tracking/where.js +306 -0
- package/dist/tracking/workspace-commands.js +94 -0
- package/dist/tracking/worktree.js +273 -0
- package/integrations/pi/postgram.ts +73 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -121,6 +121,94 @@ provenance, decisions, dependencies, blockers, ownership, involvement,
|
|
|
121
121
|
discussion participants, connected context, or ambiguous search hits. Do not
|
|
122
122
|
expand when the compact result already answers a direct fact.
|
|
123
123
|
|
|
124
|
+
## Workspace tracking
|
|
125
|
+
|
|
126
|
+
Track features, machines, workspaces and agent sessions across worktrees.
|
|
127
|
+
For onboarding and daily use, see [docs/workspace-tracking.md](../docs/workspace-tracking.md).
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
pgm here # report the current workspace and session
|
|
131
|
+
pgm hooks install # install the Claude Code hooks (--pi installs the pi extension)
|
|
132
|
+
pgm hooks uninstall # remove the installed hooks
|
|
133
|
+
pgm where # features and workspaces grouped by stage, one summary per feature (--expand lists every workspace, --all includes done and released)
|
|
134
|
+
pgm feature add "my feature" # create a feature (also: stage, pr, link, unlink)
|
|
135
|
+
pgm feature done 1234abcd # mark the feature task done; pgm where hides it unless --all
|
|
136
|
+
pgm feature sync [1234abcd] # update PR states from GitHub with gh and apply the stage rules
|
|
137
|
+
pgm machine set --alias codebox # set this machine's alias or connect target (also: pgm machine list)
|
|
138
|
+
pgm repo set <remote> --release-on-merge true|false # record the repository for this worktree
|
|
139
|
+
pgm worktree new 1234abcd # create a worktree for a feature (UUID, 8-hex short id or title; --create creates the feature)
|
|
140
|
+
pgm scan --root ~/src # discover worktrees and record their features (needs --root <dir> or scan_roots; --yes-exact, --dry-run)
|
|
141
|
+
pgm scan --json # read-only inventory for agents: one entry per worktree, nothing is sent
|
|
142
|
+
pgm scan --apply plan.json # apply an approved plan (--dry-run validates and shows the result only)
|
|
143
|
+
pgm worktree rm <path> # remove a clean, pushed worktree and unregister it (no force flag)
|
|
144
|
+
pgm workspace prune # remove registrations of this machine whose directory is gone (--dry-run, --ignored)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The interactive `pgm scan` needs a terminal. Without one it exits with a
|
|
148
|
+
message that points to `--json`, `--apply` and `--yes-exact`.
|
|
149
|
+
|
|
150
|
+
### Scan inventory and plan
|
|
151
|
+
|
|
152
|
+
`pgm scan --json` prints:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"machine": "codebox",
|
|
157
|
+
"generated_at": "2026-11-01T10:00:00.000Z",
|
|
158
|
+
"roots": ["/home/me/workspace"],
|
|
159
|
+
"worktrees": [
|
|
160
|
+
{
|
|
161
|
+
"path": "/home/me/workspace/app.worktrees/1a2b3c4d-kw-rank",
|
|
162
|
+
"repo_remote": "git@github.com:owner/app.git",
|
|
163
|
+
"branch": "feat/1a2b3c4d-kw-rank",
|
|
164
|
+
"last_commit": { "date": "2026-10-30T09:12:00+00:00", "subject": "fix: ranking" },
|
|
165
|
+
"git": { "dirty_count": 0, "unpushed_count": 1, "has_upstream": true },
|
|
166
|
+
"workspace_id": null,
|
|
167
|
+
"feature": null,
|
|
168
|
+
"marker_feature_id": null,
|
|
169
|
+
"sessions": { "count": 2, "last_activity_at": "2026-10-30T10:00:00.000Z" },
|
|
170
|
+
"candidates": [
|
|
171
|
+
{ "kind": "short-id", "feature_id": "1a2b3c4d-...", "title": "Keyword rank fusion" }
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`feature` is the link Postgram already has. `candidates` (marker, short-id,
|
|
179
|
+
search) are only computed for worktrees without a link. `git` is `null` when
|
|
180
|
+
git timed out (then `note` says so).
|
|
181
|
+
|
|
182
|
+
`pgm scan --apply plan.json` takes one entry per worktree path:
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"entries": [
|
|
187
|
+
{ "path": "/home/me/workspace/app.worktrees/1a2b3c4d-kw-rank", "action": "link", "feature_id": "1a2b3c4d" },
|
|
188
|
+
{ "path": "/home/me/workspace/app.worktrees/spike", "action": "create", "title": "Spike: new cache" },
|
|
189
|
+
{ "path": "/home/me/workspace/app.worktrees/old", "action": "skip" }
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The whole plan is validated before anything is written: paths must be
|
|
195
|
+
absolute, unique, existing worktree roots outside `ignore_paths`; `link` needs
|
|
196
|
+
a feature id (UUID or 8-hex) that exists; `create` needs a title. One invalid
|
|
197
|
+
entry rejects the plan. Entries then run one by one and the result lists
|
|
198
|
+
`linked`, `created`, `skipped` or `failed` per path (exit code 1 when any
|
|
199
|
+
failed). With `--json` the result is `{ "results": [...], "failed": n }`.
|
|
200
|
+
|
|
201
|
+
New `~/.pgmrc` keys (read alongside `api_url`/`api_key`):
|
|
202
|
+
|
|
203
|
+
| Key | Default |
|
|
204
|
+
|-----|---------|
|
|
205
|
+
| `machine` | `{ "alias": <hostname>, "connect_target": <alias> }` |
|
|
206
|
+
| `scan_roots` | `[]` |
|
|
207
|
+
| `worktree_root` | `null` (meaning `<repo_root>.worktrees`) |
|
|
208
|
+
| `here_timeout_ms` | `2000` |
|
|
209
|
+
| `ignore_paths` | `[]`. Directories `pgm here`, the hooks and `pgm scan` skip. An entry matches that directory exactly; an entry ending in `/**` matches everything below it. `~` is expanded. Example: `["~/workspace"]` skips the workspace root but not the repos in it. |
|
|
210
|
+
| `resume` | per-agent-kind `with_id`/`without_id` command templates |
|
|
211
|
+
|
|
124
212
|
## Memory Roles
|
|
125
213
|
|
|
126
214
|
Store durable memory:
|
package/dist/client.js
CHANGED
|
@@ -12,7 +12,7 @@ function normalizeErrorBody(body) {
|
|
|
12
12
|
}
|
|
13
13
|
return body;
|
|
14
14
|
}
|
|
15
|
-
async function request(options, path, requestOptions = {}) {
|
|
15
|
+
export async function request(options, path, requestOptions = {}) {
|
|
16
16
|
const headers = {
|
|
17
17
|
Authorization: `Bearer ${options.apiKey}`,
|
|
18
18
|
...(requestOptions.body === undefined
|
|
@@ -28,6 +28,9 @@ async function request(options, path, requestOptions = {}) {
|
|
|
28
28
|
if (requestOptions.body !== undefined) {
|
|
29
29
|
init.body = JSON.stringify(requestOptions.body);
|
|
30
30
|
}
|
|
31
|
+
if (requestOptions.signal !== undefined) {
|
|
32
|
+
init.signal = requestOptions.signal;
|
|
33
|
+
}
|
|
31
34
|
const response = await fetch(`${options.apiUrl}${path}`, init);
|
|
32
35
|
if (!response.ok) {
|
|
33
36
|
const body = normalizeErrorBody(await parseResponse(response));
|
package/dist/pgm.js
CHANGED
|
@@ -8,6 +8,13 @@ import { pipeline } from 'node:stream/promises';
|
|
|
8
8
|
import { createPgmClient } from './client.js';
|
|
9
9
|
import { handleCliFailure, isJsonMode, parseCommaList, parseJsonObject, printHuman, printJson, readStdinText, resolvePgmConfig, shortId } from './shared.js';
|
|
10
10
|
import { AppError, ErrorCode } from './errors.js';
|
|
11
|
+
import { registerFeatureCommands } from './tracking/feature-commands.js';
|
|
12
|
+
import { registerHereCommand } from './tracking/here.js';
|
|
13
|
+
import { registerHooksCommands } from './tracking/hooks.js';
|
|
14
|
+
import { registerScanCommand } from './tracking/scan.js';
|
|
15
|
+
import { registerWhereCommand } from './tracking/where.js';
|
|
16
|
+
import { registerWorkspaceCommands } from './tracking/workspace-commands.js';
|
|
17
|
+
import { registerWorktreeCommand } from './tracking/worktree.js';
|
|
11
18
|
import { buildSyncManifest } from './sync-walk.js';
|
|
12
19
|
import { compactEdgeResponse, compactEntityListResponse, compactGraphResponse, compactSearchResponse, compactStoredEntityResponse, entityListResponseToToon, graphResponseToToon, searchResponseToToon } from './search-output.js';
|
|
13
20
|
function readCliVersion() {
|
|
@@ -908,4 +915,11 @@ program
|
|
|
908
915
|
return lines;
|
|
909
916
|
});
|
|
910
917
|
});
|
|
918
|
+
registerHereCommand(program);
|
|
919
|
+
registerHooksCommands(program);
|
|
920
|
+
registerWhereCommand(program);
|
|
921
|
+
registerFeatureCommands(program);
|
|
922
|
+
registerWorktreeCommand(program);
|
|
923
|
+
registerScanCommand(program);
|
|
924
|
+
registerWorkspaceCommands(program);
|
|
911
925
|
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// cli/src/tracking/api.ts
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { createPgmClient, request } from '../client.js';
|
|
4
|
+
import { handleCliFailure, isJsonMode, printHuman, printJson, resolvePgmConfig } from '../shared.js';
|
|
5
|
+
import { UsageError, loadTrackingConfig } from './config.js';
|
|
6
|
+
function toQuery(params) {
|
|
7
|
+
const query = new URLSearchParams();
|
|
8
|
+
for (const [key, value] of Object.entries(params)) {
|
|
9
|
+
query.set(key, value);
|
|
10
|
+
}
|
|
11
|
+
const text = query.toString();
|
|
12
|
+
return text ? `?${text}` : '';
|
|
13
|
+
}
|
|
14
|
+
export function createTrackingClient(options) {
|
|
15
|
+
return {
|
|
16
|
+
report(body, reportOptions) {
|
|
17
|
+
return request(options, '/api/workspaces/report', {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
body,
|
|
20
|
+
...(reportOptions?.timeoutMs !== undefined
|
|
21
|
+
? { signal: AbortSignal.timeout(reportOptions.timeoutMs) }
|
|
22
|
+
: {})
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
patchWorkspace(id, body) {
|
|
26
|
+
return request(options, `/api/workspaces/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
|
27
|
+
},
|
|
28
|
+
deleteWorkspace(id) {
|
|
29
|
+
return request(options, `/api/workspaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
30
|
+
},
|
|
31
|
+
putMachine(alias, connectTarget) {
|
|
32
|
+
return request(options, `/api/machines/${encodeURIComponent(alias)}`, { method: 'PUT', body: { connect_target: connectTarget } });
|
|
33
|
+
},
|
|
34
|
+
listMachines() {
|
|
35
|
+
return request(options, '/api/machines');
|
|
36
|
+
},
|
|
37
|
+
putRepository(remote, releaseOnMerge) {
|
|
38
|
+
return request(options, '/api/repositories', {
|
|
39
|
+
method: 'PUT',
|
|
40
|
+
body: { remote, release_on_merge: releaseOnMerge }
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
createFeature(input) {
|
|
44
|
+
return request(options, '/api/features', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
body: input
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
setStage(id, stage) {
|
|
50
|
+
return request(options, `/api/features/${encodeURIComponent(id)}/stage`, { method: 'POST', body: { stage } });
|
|
51
|
+
},
|
|
52
|
+
addPullRequest(id, input) {
|
|
53
|
+
return request(options, `/api/features/${encodeURIComponent(id)}/pull-requests`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body: input
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
lookup(machine, path) {
|
|
59
|
+
return request(options, `/api/workspaces/lookup${toQuery({ machine, path })}`);
|
|
60
|
+
},
|
|
61
|
+
listWorkspaces(query) {
|
|
62
|
+
const params = {};
|
|
63
|
+
if (query.machine !== undefined) {
|
|
64
|
+
params.machine = query.machine;
|
|
65
|
+
}
|
|
66
|
+
if (query.repo !== undefined) {
|
|
67
|
+
params.repo = query.repo;
|
|
68
|
+
}
|
|
69
|
+
if (query.featureId !== undefined) {
|
|
70
|
+
params.feature_id = query.featureId;
|
|
71
|
+
}
|
|
72
|
+
if (query.unassigned) {
|
|
73
|
+
params.unassigned = 'true';
|
|
74
|
+
}
|
|
75
|
+
if (query.includeEnded) {
|
|
76
|
+
params.include_ended = 'true';
|
|
77
|
+
}
|
|
78
|
+
return request(options, `/api/workspaces${toQuery(params)}`);
|
|
79
|
+
},
|
|
80
|
+
where(query) {
|
|
81
|
+
const params = {};
|
|
82
|
+
if (query.view !== undefined) {
|
|
83
|
+
params.view = query.view;
|
|
84
|
+
}
|
|
85
|
+
if (query.machine !== undefined) {
|
|
86
|
+
params.machine = query.machine;
|
|
87
|
+
}
|
|
88
|
+
if (query.repo !== undefined) {
|
|
89
|
+
params.repo = query.repo;
|
|
90
|
+
}
|
|
91
|
+
if (query.includeReleased) {
|
|
92
|
+
params.include_released = 'true';
|
|
93
|
+
}
|
|
94
|
+
if (query.includeDone) {
|
|
95
|
+
params.include_done = 'true';
|
|
96
|
+
}
|
|
97
|
+
if (query.includeEnded) {
|
|
98
|
+
params.include_ended = 'true';
|
|
99
|
+
}
|
|
100
|
+
return request(options, `/api/where${toQuery(params)}`);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export async function runTrackingCommand(command, handler) {
|
|
105
|
+
const json = isJsonMode(command);
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
const homeDir = os.homedir();
|
|
109
|
+
const config = await loadTrackingConfig();
|
|
110
|
+
const credentials = await resolvePgmConfig();
|
|
111
|
+
result = await handler({
|
|
112
|
+
json,
|
|
113
|
+
config,
|
|
114
|
+
client: createTrackingClient(credentials),
|
|
115
|
+
pgm: createPgmClient(credentials),
|
|
116
|
+
homeDir
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof UsageError) {
|
|
121
|
+
process.stderr.write(`${error.message}\n`);
|
|
122
|
+
process.exitCode = 2;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await handleCliFailure(error, json);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (result === undefined) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(result)) {
|
|
132
|
+
if (json) {
|
|
133
|
+
printJson(result);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
printHuman(result.map(String));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
printJson(result);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// cli/src/tracking/config.ts
|
|
2
|
+
import { chmod, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AGENT_KINDS } from './types.js';
|
|
6
|
+
export const DEFAULT_RESUME = {
|
|
7
|
+
'claude-code': { with_id: 'claude --resume {session_id}', without_id: 'claude' },
|
|
8
|
+
pi: { with_id: 'pi --session {session_id}', without_id: 'pi' },
|
|
9
|
+
talon: { with_id: null, without_id: null },
|
|
10
|
+
other: { with_id: null, without_id: null }
|
|
11
|
+
};
|
|
12
|
+
export class UsageError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export function shq(value) {
|
|
15
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
16
|
+
}
|
|
17
|
+
export function slug(title) {
|
|
18
|
+
return (title
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
.slice(0, 40)
|
|
23
|
+
.replace(/-+$/, '') || 'feature');
|
|
24
|
+
}
|
|
25
|
+
export function defaultAlias(hostname) {
|
|
26
|
+
const head = hostname.toLowerCase().split('.')[0] ?? '';
|
|
27
|
+
return head.replace(/[^a-z0-9._-]/g, '-') || 'machine';
|
|
28
|
+
}
|
|
29
|
+
export function expandHome(value, homeDir) {
|
|
30
|
+
if (value === '~') {
|
|
31
|
+
return homeDir;
|
|
32
|
+
}
|
|
33
|
+
if (value.startsWith('~/')) {
|
|
34
|
+
return path.join(homeDir, value.slice(2));
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function trimTrailingSlashes(value) {
|
|
39
|
+
return value.length > 1 ? value.replace(/\/+$/, '') || '/' : value;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* An entry matches that directory exactly. An entry ending in `/**` matches
|
|
43
|
+
* everything below the directory, but not the directory itself.
|
|
44
|
+
*/
|
|
45
|
+
export function isIgnoredPath(target, ignorePaths) {
|
|
46
|
+
const candidate = trimTrailingSlashes(target);
|
|
47
|
+
return ignorePaths.some((entry) => {
|
|
48
|
+
if (entry.endsWith('/**')) {
|
|
49
|
+
const base = trimTrailingSlashes(entry.slice(0, -3));
|
|
50
|
+
return candidate.startsWith(base === '/' ? '/' : `${base}/`);
|
|
51
|
+
}
|
|
52
|
+
return candidate === trimTrailingSlashes(entry);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function isRecord(value) {
|
|
56
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
function nonEmptyString(value) {
|
|
59
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
60
|
+
}
|
|
61
|
+
function cloneResume(templates) {
|
|
62
|
+
const result = {};
|
|
63
|
+
for (const kind of AGENT_KINDS) {
|
|
64
|
+
const base = templates[kind];
|
|
65
|
+
result[kind] = { with_id: base.with_id, without_id: base.without_id };
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
export function parseTrackingConfig(raw, input) {
|
|
70
|
+
const machine = isRecord(raw) ? (isRecord(raw.machine) ? raw.machine : {}) : {};
|
|
71
|
+
const alias = nonEmptyString(machine.alias) ?? defaultAlias(input.hostname);
|
|
72
|
+
const connectTarget = nonEmptyString(machine.connect_target) ?? alias;
|
|
73
|
+
const scanRoots = [];
|
|
74
|
+
if (isRecord(raw) && Array.isArray(raw.scan_roots)) {
|
|
75
|
+
for (const entry of raw.scan_roots) {
|
|
76
|
+
if (typeof entry === 'string') {
|
|
77
|
+
scanRoots.push(expandHome(entry, input.homeDir));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const worktreeRoot = isRecord(raw) && typeof raw.worktree_root === 'string'
|
|
82
|
+
? expandHome(raw.worktree_root, input.homeDir)
|
|
83
|
+
: null;
|
|
84
|
+
const timeout = isRecord(raw) ? raw.here_timeout_ms : undefined;
|
|
85
|
+
const hereTimeoutMs = typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0
|
|
86
|
+
? timeout
|
|
87
|
+
: 2000;
|
|
88
|
+
const ignorePaths = [];
|
|
89
|
+
if (isRecord(raw) && Array.isArray(raw.ignore_paths)) {
|
|
90
|
+
for (const entry of raw.ignore_paths) {
|
|
91
|
+
if (typeof entry === 'string' && entry !== '') {
|
|
92
|
+
ignorePaths.push(expandHome(entry, input.homeDir));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const resume = cloneResume(DEFAULT_RESUME);
|
|
97
|
+
const rawResume = isRecord(raw) && isRecord(raw.resume) ? raw.resume : {};
|
|
98
|
+
for (const kind of AGENT_KINDS) {
|
|
99
|
+
const kindConfig = rawResume[kind];
|
|
100
|
+
if (!isRecord(kindConfig)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (typeof kindConfig.with_id === 'string' || kindConfig.with_id === null) {
|
|
104
|
+
resume[kind].with_id = kindConfig.with_id;
|
|
105
|
+
}
|
|
106
|
+
if (typeof kindConfig.without_id === 'string' ||
|
|
107
|
+
kindConfig.without_id === null) {
|
|
108
|
+
resume[kind].without_id = kindConfig.without_id;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
alias,
|
|
113
|
+
connectTarget,
|
|
114
|
+
scanRoots,
|
|
115
|
+
worktreeRoot,
|
|
116
|
+
hereTimeoutMs,
|
|
117
|
+
ignorePaths,
|
|
118
|
+
resume
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export async function loadTrackingConfig(input) {
|
|
122
|
+
const homeDir = input?.homeDir ?? os.homedir();
|
|
123
|
+
const hostname = input?.hostname ?? os.hostname();
|
|
124
|
+
let raw = {};
|
|
125
|
+
try {
|
|
126
|
+
const text = await readFile(path.join(homeDir, '.pgmrc'), 'utf8');
|
|
127
|
+
raw = JSON.parse(text);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
raw = {};
|
|
131
|
+
}
|
|
132
|
+
return parseTrackingConfig(raw, { homeDir, hostname });
|
|
133
|
+
}
|
|
134
|
+
export async function writeMachineConfig(input) {
|
|
135
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
136
|
+
const rcPath = path.join(homeDir, '.pgmrc');
|
|
137
|
+
let doc = {};
|
|
138
|
+
let text = null;
|
|
139
|
+
try {
|
|
140
|
+
text = await readFile(rcPath, 'utf8');
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const code = error instanceof Error && 'code' in error
|
|
144
|
+
? error.code
|
|
145
|
+
: undefined;
|
|
146
|
+
if (code !== 'ENOENT') {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (text !== null) {
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(text);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw new UsageError(`~/.pgmrc contains invalid JSON: ${error instanceof Error ? error.message : 'parse failed'}`);
|
|
157
|
+
}
|
|
158
|
+
if (!isRecord(parsed)) {
|
|
159
|
+
throw new UsageError('~/.pgmrc must be a JSON object');
|
|
160
|
+
}
|
|
161
|
+
doc = parsed;
|
|
162
|
+
}
|
|
163
|
+
const machine = isRecord(doc.machine) ? doc.machine : {};
|
|
164
|
+
const previousAlias = nonEmptyString(machine.alias);
|
|
165
|
+
const alias = nonEmptyString(input.alias) ??
|
|
166
|
+
previousAlias ??
|
|
167
|
+
defaultAlias(os.hostname());
|
|
168
|
+
const connectTarget = nonEmptyString(input.connectTarget) ??
|
|
169
|
+
nonEmptyString(machine.connect_target) ??
|
|
170
|
+
alias;
|
|
171
|
+
doc.machine = { ...machine, alias, connect_target: connectTarget };
|
|
172
|
+
await writeFile(rcPath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
173
|
+
await chmod(rcPath, 0o600);
|
|
174
|
+
return { previousAlias, alias, connectTarget };
|
|
175
|
+
}
|
|
176
|
+
export function usageExit(command) {
|
|
177
|
+
command.exitOverride((error) => {
|
|
178
|
+
process.exit(error.exitCode === 0 ? 0 : 2);
|
|
179
|
+
});
|
|
180
|
+
return command;
|
|
181
|
+
}
|