@ivotoby/postgram-cli 1.38.2 → 1.39.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 +26 -0
- package/dist/client.js +4 -1
- package/dist/pgm.js +12 -0
- package/dist/tracking/api.js +139 -0
- package/dist/tracking/config.js +148 -0
- package/dist/tracking/feature-commands.js +228 -0
- package/dist/tracking/here.js +267 -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 +261 -0
- package/dist/tracking/types.js +14 -0
- package/dist/tracking/where.js +231 -0
- package/dist/tracking/worktree.js +187 -0
- package/integrations/pi/postgram.ts +73 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -121,6 +121,32 @@ 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
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pgm here # report the current workspace and session
|
|
130
|
+
pgm hooks install # install the Claude Code hooks (--pi installs the pi extension)
|
|
131
|
+
pgm hooks uninstall # remove the installed hooks
|
|
132
|
+
pgm where # features and workspaces grouped by stage
|
|
133
|
+
pgm feature add "my feature" # create a feature (also: stage, pr, link, unlink)
|
|
134
|
+
pgm machine set --alias codebox # set this machine's alias or connect target (also: pgm machine list)
|
|
135
|
+
pgm repo set <remote> --release-on-merge true|false # record the repository for this worktree
|
|
136
|
+
pgm worktree new 1234abcd # create a worktree for a feature (UUID, 8-hex short id or title; --create creates the feature)
|
|
137
|
+
pgm scan --root ~/src # discover worktrees and record their features (needs --root <dir> or scan_roots; --yes-exact, --dry-run)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
New `~/.pgmrc` keys (read alongside `api_url`/`api_key`):
|
|
141
|
+
|
|
142
|
+
| Key | Default |
|
|
143
|
+
|-----|---------|
|
|
144
|
+
| `machine` | `{ "alias": <hostname>, "connect_target": <alias> }` |
|
|
145
|
+
| `scan_roots` | `[]` |
|
|
146
|
+
| `worktree_root` | `null` (meaning `<repo_root>.worktrees`) |
|
|
147
|
+
| `here_timeout_ms` | `2000` |
|
|
148
|
+
| `resume` | per-agent-kind `with_id`/`without_id` command templates |
|
|
149
|
+
|
|
124
150
|
## Memory Roles
|
|
125
151
|
|
|
126
152
|
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,12 @@ 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 { registerWorktreeCommand } from './tracking/worktree.js';
|
|
11
17
|
import { buildSyncManifest } from './sync-walk.js';
|
|
12
18
|
import { compactEdgeResponse, compactEntityListResponse, compactGraphResponse, compactSearchResponse, compactStoredEntityResponse, entityListResponseToToon, graphResponseToToon, searchResponseToToon } from './search-output.js';
|
|
13
19
|
function readCliVersion() {
|
|
@@ -908,4 +914,10 @@ program
|
|
|
908
914
|
return lines;
|
|
909
915
|
});
|
|
910
916
|
});
|
|
917
|
+
registerHereCommand(program);
|
|
918
|
+
registerHooksCommands(program);
|
|
919
|
+
registerWhereCommand(program);
|
|
920
|
+
registerFeatureCommands(program);
|
|
921
|
+
registerWorktreeCommand(program);
|
|
922
|
+
registerScanCommand(program);
|
|
911
923
|
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
putMachine(alias, connectTarget) {
|
|
29
|
+
return request(options, `/api/machines/${encodeURIComponent(alias)}`, { method: 'PUT', body: { connect_target: connectTarget } });
|
|
30
|
+
},
|
|
31
|
+
listMachines() {
|
|
32
|
+
return request(options, '/api/machines');
|
|
33
|
+
},
|
|
34
|
+
putRepository(remote, releaseOnMerge) {
|
|
35
|
+
return request(options, '/api/repositories', {
|
|
36
|
+
method: 'PUT',
|
|
37
|
+
body: { remote, release_on_merge: releaseOnMerge }
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
createFeature(input) {
|
|
41
|
+
return request(options, '/api/features', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
body: input
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
setStage(id, stage) {
|
|
47
|
+
return request(options, `/api/features/${encodeURIComponent(id)}/stage`, { method: 'POST', body: { stage } });
|
|
48
|
+
},
|
|
49
|
+
addPullRequest(id, input) {
|
|
50
|
+
return request(options, `/api/features/${encodeURIComponent(id)}/pull-requests`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: input
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
lookup(machine, path) {
|
|
56
|
+
return request(options, `/api/workspaces/lookup${toQuery({ machine, path })}`);
|
|
57
|
+
},
|
|
58
|
+
listWorkspaces(query) {
|
|
59
|
+
const params = {};
|
|
60
|
+
if (query.machine !== undefined) {
|
|
61
|
+
params.machine = query.machine;
|
|
62
|
+
}
|
|
63
|
+
if (query.repo !== undefined) {
|
|
64
|
+
params.repo = query.repo;
|
|
65
|
+
}
|
|
66
|
+
if (query.featureId !== undefined) {
|
|
67
|
+
params.feature_id = query.featureId;
|
|
68
|
+
}
|
|
69
|
+
if (query.unassigned) {
|
|
70
|
+
params.unassigned = 'true';
|
|
71
|
+
}
|
|
72
|
+
if (query.includeEnded) {
|
|
73
|
+
params.include_ended = 'true';
|
|
74
|
+
}
|
|
75
|
+
return request(options, `/api/workspaces${toQuery(params)}`);
|
|
76
|
+
},
|
|
77
|
+
where(query) {
|
|
78
|
+
const params = {};
|
|
79
|
+
if (query.view !== undefined) {
|
|
80
|
+
params.view = query.view;
|
|
81
|
+
}
|
|
82
|
+
if (query.machine !== undefined) {
|
|
83
|
+
params.machine = query.machine;
|
|
84
|
+
}
|
|
85
|
+
if (query.repo !== undefined) {
|
|
86
|
+
params.repo = query.repo;
|
|
87
|
+
}
|
|
88
|
+
if (query.includeReleased) {
|
|
89
|
+
params.include_released = 'true';
|
|
90
|
+
}
|
|
91
|
+
if (query.includeDone) {
|
|
92
|
+
params.include_done = 'true';
|
|
93
|
+
}
|
|
94
|
+
if (query.includeEnded) {
|
|
95
|
+
params.include_ended = 'true';
|
|
96
|
+
}
|
|
97
|
+
return request(options, `/api/where${toQuery(params)}`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function runTrackingCommand(command, handler) {
|
|
102
|
+
const json = isJsonMode(command);
|
|
103
|
+
let result;
|
|
104
|
+
try {
|
|
105
|
+
const homeDir = os.homedir();
|
|
106
|
+
const config = await loadTrackingConfig();
|
|
107
|
+
const credentials = await resolvePgmConfig();
|
|
108
|
+
result = await handler({
|
|
109
|
+
json,
|
|
110
|
+
config,
|
|
111
|
+
client: createTrackingClient(credentials),
|
|
112
|
+
pgm: createPgmClient(credentials),
|
|
113
|
+
homeDir
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error instanceof UsageError) {
|
|
118
|
+
process.stderr.write(`${error.message}\n`);
|
|
119
|
+
process.exitCode = 2;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await handleCliFailure(error, json);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (result === undefined) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(result)) {
|
|
129
|
+
if (json) {
|
|
130
|
+
printJson(result);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
printHuman(result.map(String));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
printJson(result);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 isRecord(value) {
|
|
39
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
function nonEmptyString(value) {
|
|
42
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
43
|
+
}
|
|
44
|
+
function cloneResume(templates) {
|
|
45
|
+
const result = {};
|
|
46
|
+
for (const kind of AGENT_KINDS) {
|
|
47
|
+
const base = templates[kind];
|
|
48
|
+
result[kind] = { with_id: base.with_id, without_id: base.without_id };
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
export function parseTrackingConfig(raw, input) {
|
|
53
|
+
const machine = isRecord(raw) ? (isRecord(raw.machine) ? raw.machine : {}) : {};
|
|
54
|
+
const alias = nonEmptyString(machine.alias) ?? defaultAlias(input.hostname);
|
|
55
|
+
const connectTarget = nonEmptyString(machine.connect_target) ?? alias;
|
|
56
|
+
const scanRoots = [];
|
|
57
|
+
if (isRecord(raw) && Array.isArray(raw.scan_roots)) {
|
|
58
|
+
for (const entry of raw.scan_roots) {
|
|
59
|
+
if (typeof entry === 'string') {
|
|
60
|
+
scanRoots.push(expandHome(entry, input.homeDir));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const worktreeRoot = isRecord(raw) && typeof raw.worktree_root === 'string'
|
|
65
|
+
? expandHome(raw.worktree_root, input.homeDir)
|
|
66
|
+
: null;
|
|
67
|
+
const timeout = isRecord(raw) ? raw.here_timeout_ms : undefined;
|
|
68
|
+
const hereTimeoutMs = typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0
|
|
69
|
+
? timeout
|
|
70
|
+
: 2000;
|
|
71
|
+
const resume = cloneResume(DEFAULT_RESUME);
|
|
72
|
+
const rawResume = isRecord(raw) && isRecord(raw.resume) ? raw.resume : {};
|
|
73
|
+
for (const kind of AGENT_KINDS) {
|
|
74
|
+
const kindConfig = rawResume[kind];
|
|
75
|
+
if (!isRecord(kindConfig)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (typeof kindConfig.with_id === 'string' || kindConfig.with_id === null) {
|
|
79
|
+
resume[kind].with_id = kindConfig.with_id;
|
|
80
|
+
}
|
|
81
|
+
if (typeof kindConfig.without_id === 'string' ||
|
|
82
|
+
kindConfig.without_id === null) {
|
|
83
|
+
resume[kind].without_id = kindConfig.without_id;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { alias, connectTarget, scanRoots, worktreeRoot, hereTimeoutMs, resume };
|
|
87
|
+
}
|
|
88
|
+
export async function loadTrackingConfig(input) {
|
|
89
|
+
const homeDir = input?.homeDir ?? os.homedir();
|
|
90
|
+
const hostname = input?.hostname ?? os.hostname();
|
|
91
|
+
let raw = {};
|
|
92
|
+
try {
|
|
93
|
+
const text = await readFile(path.join(homeDir, '.pgmrc'), 'utf8');
|
|
94
|
+
raw = JSON.parse(text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
raw = {};
|
|
98
|
+
}
|
|
99
|
+
return parseTrackingConfig(raw, { homeDir, hostname });
|
|
100
|
+
}
|
|
101
|
+
export async function writeMachineConfig(input) {
|
|
102
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
103
|
+
const rcPath = path.join(homeDir, '.pgmrc');
|
|
104
|
+
let doc = {};
|
|
105
|
+
let text = null;
|
|
106
|
+
try {
|
|
107
|
+
text = await readFile(rcPath, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const code = error instanceof Error && 'code' in error
|
|
111
|
+
? error.code
|
|
112
|
+
: undefined;
|
|
113
|
+
if (code !== 'ENOENT') {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (text !== null) {
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(text);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw new UsageError(`~/.pgmrc contains invalid JSON: ${error instanceof Error ? error.message : 'parse failed'}`);
|
|
124
|
+
}
|
|
125
|
+
if (!isRecord(parsed)) {
|
|
126
|
+
throw new UsageError('~/.pgmrc must be a JSON object');
|
|
127
|
+
}
|
|
128
|
+
doc = parsed;
|
|
129
|
+
}
|
|
130
|
+
const machine = isRecord(doc.machine) ? doc.machine : {};
|
|
131
|
+
const previousAlias = nonEmptyString(machine.alias);
|
|
132
|
+
const alias = nonEmptyString(input.alias) ??
|
|
133
|
+
previousAlias ??
|
|
134
|
+
defaultAlias(os.hostname());
|
|
135
|
+
const connectTarget = nonEmptyString(input.connectTarget) ??
|
|
136
|
+
nonEmptyString(machine.connect_target) ??
|
|
137
|
+
alias;
|
|
138
|
+
doc.machine = { ...machine, alias, connect_target: connectTarget };
|
|
139
|
+
await writeFile(rcPath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
140
|
+
await chmod(rcPath, 0o600);
|
|
141
|
+
return { previousAlias, alias, connectTarget };
|
|
142
|
+
}
|
|
143
|
+
export function usageExit(command) {
|
|
144
|
+
command.exitOverride((error) => {
|
|
145
|
+
process.exit(error.exitCode === 0 ? 0 : 2);
|
|
146
|
+
});
|
|
147
|
+
return command;
|
|
148
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// cli/src/tracking/feature-commands.ts: pgm feature, pgm machine, pgm repo.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { AppError, ErrorCode } from '../errors.js';
|
|
5
|
+
import { parseCommaList, shortId } from '../shared.js';
|
|
6
|
+
import { runTrackingCommand } from './api.js';
|
|
7
|
+
import { UsageError, usageExit, writeMachineConfig } from './config.js';
|
|
8
|
+
import { readBootId, removeMarker, resolveLocalWorkspace, writeMarker } from './local.js';
|
|
9
|
+
import { FEATURE_STAGES } from './types.js';
|
|
10
|
+
export async function runFeatureAdd(title, options, deps) {
|
|
11
|
+
const tags = parseCommaList(options.tags);
|
|
12
|
+
const response = await deps.client.createFeature({
|
|
13
|
+
content: title,
|
|
14
|
+
...(tags !== undefined ? { tags } : {}),
|
|
15
|
+
...(options.visibility !== undefined ? { visibility: options.visibility } : {})
|
|
16
|
+
});
|
|
17
|
+
return deps.json ? response : [response.entity.id];
|
|
18
|
+
}
|
|
19
|
+
export async function runFeatureStage(id, stage, deps) {
|
|
20
|
+
if (!FEATURE_STAGES.includes(stage)) {
|
|
21
|
+
throw new UsageError(`invalid stage '${stage}'. Use one of: ${FEATURE_STAGES.join(', ')}`);
|
|
22
|
+
}
|
|
23
|
+
const response = await deps.client.setStage(id, stage);
|
|
24
|
+
return deps.json
|
|
25
|
+
? response
|
|
26
|
+
: [`${shortId(response.entity.id)} ${response.previous_stage ?? '-'} → ${stage}`];
|
|
27
|
+
}
|
|
28
|
+
const PR_STATES = ['open', 'merged', 'closed'];
|
|
29
|
+
export async function runFeaturePr(id, url, options, deps) {
|
|
30
|
+
const state = options.state ?? 'open';
|
|
31
|
+
if (!PR_STATES.includes(state)) {
|
|
32
|
+
throw new UsageError(`invalid --state '${state}'. Use one of: ${PR_STATES.join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
const response = await deps.client.addPullRequest(id, {
|
|
35
|
+
url,
|
|
36
|
+
state: state
|
|
37
|
+
});
|
|
38
|
+
return deps.json
|
|
39
|
+
? response
|
|
40
|
+
: [`${shortId(response.entity.id)} PR ${state}, stage ${response.to}`];
|
|
41
|
+
}
|
|
42
|
+
export async function runFeatureLink(id, options, deps) {
|
|
43
|
+
if (options.workspace !== undefined) {
|
|
44
|
+
const patched = await deps.client.patchWorkspace(options.workspace, {
|
|
45
|
+
feature_id: id
|
|
46
|
+
});
|
|
47
|
+
const workspace = patched.workspace;
|
|
48
|
+
if (workspace.machine === deps.config.alias && deps.exists(workspace.path)) {
|
|
49
|
+
if (id !== null) {
|
|
50
|
+
await deps.writeMarker(workspace.path, workspace.feature_id ?? id);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
await deps.removeMarker(workspace.path);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
deps.warn(`warning: ${workspace.machine} may have a feature marker; its next registration will restore the marker's link`);
|
|
58
|
+
}
|
|
59
|
+
return deps.json
|
|
60
|
+
? patched
|
|
61
|
+
: [
|
|
62
|
+
id !== null
|
|
63
|
+
? `linked ${workspace.path} → ${shortId(id)}`
|
|
64
|
+
: `unlinked ${workspace.path}`
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
const target = options.path ?? deps.cwd;
|
|
68
|
+
const local = await deps.resolveWorkspace(target);
|
|
69
|
+
if (local.kind === 'timeout') {
|
|
70
|
+
throw new AppError(ErrorCode.INTERNAL, 'git timeout');
|
|
71
|
+
}
|
|
72
|
+
const looked = await deps.client.lookup(deps.config.alias, local.path);
|
|
73
|
+
let workspaceId;
|
|
74
|
+
if (looked.workspace !== null && looked.workspace.path === local.path) {
|
|
75
|
+
workspaceId = looked.workspace.id;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const report = await deps.client.report({
|
|
79
|
+
source: 'hook',
|
|
80
|
+
machine: {
|
|
81
|
+
alias: deps.config.alias,
|
|
82
|
+
hostname: deps.hostname,
|
|
83
|
+
boot_id: await deps.bootId()
|
|
84
|
+
},
|
|
85
|
+
workspace: local.workspace
|
|
86
|
+
});
|
|
87
|
+
if (report.workspace === null) {
|
|
88
|
+
throw new AppError(ErrorCode.INTERNAL, 'workspace registration returned no workspace');
|
|
89
|
+
}
|
|
90
|
+
workspaceId = report.workspace.id;
|
|
91
|
+
}
|
|
92
|
+
const patched = await deps.client.patchWorkspace(workspaceId, {
|
|
93
|
+
feature_id: id
|
|
94
|
+
});
|
|
95
|
+
if (id !== null) {
|
|
96
|
+
await deps.writeMarker(local.path, patched.workspace.feature_id ?? id);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
await deps.removeMarker(local.path);
|
|
100
|
+
}
|
|
101
|
+
return deps.json
|
|
102
|
+
? patched
|
|
103
|
+
: [
|
|
104
|
+
id !== null
|
|
105
|
+
? `linked ${local.path} → ${shortId(id)}`
|
|
106
|
+
: `unlinked ${local.path}`
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
export async function runMachineSet(options, deps) {
|
|
110
|
+
const written = await deps.writeConfig({
|
|
111
|
+
...(options.alias !== undefined ? { alias: options.alias } : {}),
|
|
112
|
+
...(options.connect !== undefined ? { connectTarget: options.connect } : {}),
|
|
113
|
+
homeDir: deps.homeDir
|
|
114
|
+
});
|
|
115
|
+
if (written.previousAlias !== null && written.previousAlias !== written.alias) {
|
|
116
|
+
deps.warn(`warning: history stays under the old alias '${written.previousAlias}'`);
|
|
117
|
+
}
|
|
118
|
+
await deps.client.report({
|
|
119
|
+
source: 'hook',
|
|
120
|
+
machine: {
|
|
121
|
+
alias: written.alias,
|
|
122
|
+
hostname: deps.hostname,
|
|
123
|
+
boot_id: await deps.bootId()
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
const result = await deps.client.putMachine(written.alias, written.connectTarget);
|
|
127
|
+
return deps.json
|
|
128
|
+
? result
|
|
129
|
+
: [`machine ${written.alias} → ${written.connectTarget}`];
|
|
130
|
+
}
|
|
131
|
+
export async function runMachineList(deps) {
|
|
132
|
+
const result = await deps.client.listMachines();
|
|
133
|
+
return deps.json
|
|
134
|
+
? result
|
|
135
|
+
: result.machines.map((machine) => `${machine.alias} ${machine.connect_target ?? machine.alias} ${machine.last_seen_at}`);
|
|
136
|
+
}
|
|
137
|
+
export async function runRepoSet(remote, options, deps) {
|
|
138
|
+
if (options.releaseOnMerge !== 'true' && options.releaseOnMerge !== 'false') {
|
|
139
|
+
throw new UsageError(`--release-on-merge must be 'true' or 'false'`);
|
|
140
|
+
}
|
|
141
|
+
const releaseOnMerge = options.releaseOnMerge === 'true';
|
|
142
|
+
const result = await deps.client.putRepository(remote, releaseOnMerge);
|
|
143
|
+
return deps.json ? result : [`${remote} release_on_merge=${releaseOnMerge}`];
|
|
144
|
+
}
|
|
145
|
+
function featureDeps(context) {
|
|
146
|
+
return {
|
|
147
|
+
client: context.client,
|
|
148
|
+
config: context.config,
|
|
149
|
+
json: context.json,
|
|
150
|
+
cwd: process.cwd(),
|
|
151
|
+
hostname: os.hostname(),
|
|
152
|
+
homeDir: context.homeDir,
|
|
153
|
+
bootId: readBootId,
|
|
154
|
+
resolveWorkspace: (cwd) => resolveLocalWorkspace(cwd),
|
|
155
|
+
writeMarker: (worktree, featureId) => writeMarker(worktree, featureId, new Date()),
|
|
156
|
+
removeMarker,
|
|
157
|
+
exists: existsSync,
|
|
158
|
+
warn: (line) => process.stderr.write(`${line}\n`)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export function registerFeatureCommands(program) {
|
|
162
|
+
const feature = usageExit(program
|
|
163
|
+
.command('feature')
|
|
164
|
+
.description('Manage features'));
|
|
165
|
+
usageExit(feature
|
|
166
|
+
.command('add')
|
|
167
|
+
.description('Create a feature')
|
|
168
|
+
.argument('<title>', 'feature title')
|
|
169
|
+
.option('--tags <tags>', 'comma-separated tags')
|
|
170
|
+
.option('--visibility <visibility>', 'personal, work or shared')).action(async (title, options, command) => {
|
|
171
|
+
await runTrackingCommand(command, (context) => runFeatureAdd(title, options, featureDeps(context)));
|
|
172
|
+
});
|
|
173
|
+
usageExit(feature
|
|
174
|
+
.command('stage')
|
|
175
|
+
.description('Set a feature stage')
|
|
176
|
+
.argument('<id>', 'feature id (UUID or 8-hex)')
|
|
177
|
+
.argument('<stage>', `one of: ${FEATURE_STAGES.join(', ')}`)).action(async (id, stage, _options, command) => {
|
|
178
|
+
await runTrackingCommand(command, (context) => runFeatureStage(id, stage, featureDeps(context)));
|
|
179
|
+
});
|
|
180
|
+
usageExit(feature
|
|
181
|
+
.command('pr')
|
|
182
|
+
.description('Attach a pull request to a feature')
|
|
183
|
+
.argument('<id>', 'feature id (UUID or 8-hex)')
|
|
184
|
+
.argument('<url>', 'pull request URL')
|
|
185
|
+
.option('--state <state>', 'open, merged or closed', 'open')).action(async (id, url, options, command) => {
|
|
186
|
+
await runTrackingCommand(command, (context) => runFeaturePr(id, url, options, featureDeps(context)));
|
|
187
|
+
});
|
|
188
|
+
usageExit(feature
|
|
189
|
+
.command('link')
|
|
190
|
+
.description('Link a workspace to a feature')
|
|
191
|
+
.argument('<id>', 'feature id (UUID or 8-hex)')
|
|
192
|
+
.option('--path <dir>', 'workspace directory (default: cwd)')
|
|
193
|
+
.option('--workspace <workspace-uuid>', 'link by workspace id instead')).action(async (id, options, command) => {
|
|
194
|
+
await runTrackingCommand(command, (context) => runFeatureLink(id, options, featureDeps(context)));
|
|
195
|
+
});
|
|
196
|
+
usageExit(feature
|
|
197
|
+
.command('unlink')
|
|
198
|
+
.description('Unlink a workspace from its feature')
|
|
199
|
+
.option('--path <dir>', 'workspace directory (default: cwd)')
|
|
200
|
+
.option('--workspace <workspace-uuid>', 'unlink by workspace id instead')).action(async (options, command) => {
|
|
201
|
+
await runTrackingCommand(command, (context) => runFeatureLink(null, options, featureDeps(context)));
|
|
202
|
+
});
|
|
203
|
+
const machine = usageExit(program
|
|
204
|
+
.command('machine')
|
|
205
|
+
.description('Machine settings'));
|
|
206
|
+
usageExit(machine
|
|
207
|
+
.command('set')
|
|
208
|
+
.description('Write ~/.pgmrc and register the machine')
|
|
209
|
+
.option('--alias <alias>', 'machine alias')
|
|
210
|
+
.option('--connect <target>', 'connect target')).action(async (options, command) => {
|
|
211
|
+
await runTrackingCommand(command, (context) => runMachineSet(options, { ...featureDeps(context), writeConfig: writeMachineConfig }));
|
|
212
|
+
});
|
|
213
|
+
usageExit(machine
|
|
214
|
+
.command('list')
|
|
215
|
+
.description('List machines')).action(async (_options, command) => {
|
|
216
|
+
await runTrackingCommand(command, (context) => runMachineList(featureDeps(context)));
|
|
217
|
+
});
|
|
218
|
+
const repo = usageExit(program
|
|
219
|
+
.command('repo')
|
|
220
|
+
.description('Repository settings'));
|
|
221
|
+
usageExit(repo
|
|
222
|
+
.command('set')
|
|
223
|
+
.description('Set repository settings')
|
|
224
|
+
.argument('<remote>', 'repository remote')
|
|
225
|
+
.option('--release-on-merge <bool>', 'true or false')).action(async (remote, options, command) => {
|
|
226
|
+
await runTrackingCommand(command, (context) => runRepoSet(remote, options, featureDeps(context)));
|
|
227
|
+
});
|
|
228
|
+
}
|