@larktask/aamp-feishu-task-agent 0.1.0-dev.171
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 +153 -0
- package/bin/aamp-logs.mjs +523 -0
- package/bin/feishu-task-agent-controller.mjs +1899 -0
- package/bin/runtime-network.mjs +326 -0
- package/bootstrap/aamp-feishu-task-agent-bootstrap.sh +3834 -0
- package/package.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# aamp-feishu-task-agent
|
|
2
|
+
|
|
3
|
+
One-click manager for binding local Codex/Cursor agents to user-owned Feishu
|
|
4
|
+
Bots and running the corresponding Task bridges.
|
|
5
|
+
|
|
6
|
+
## Install and bind
|
|
7
|
+
|
|
8
|
+
Run the standalone one-click command. Its existing Node.js/npm dependency
|
|
9
|
+
installation flow is unchanged:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx -y --package @larktask/aamp-feishu-task-agent@dev \
|
|
13
|
+
feishu-task-agent install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`install` first collects local Agent and Feishu Bot choices until the user
|
|
17
|
+
chooses not to continue. No Bridge is started while choices are being
|
|
18
|
+
collected. The same Agent may be bound to multiple Bots, while a Bot can only
|
|
19
|
+
be selected once. After selection, one Agent Bridge group is prepared and each
|
|
20
|
+
pair is started serially with a real one-time ACP pairing. A successful first
|
|
21
|
+
Feishu Bridge start is kept as the final running process instead of being
|
|
22
|
+
stopped and started a second time. The successfully completed bindings then
|
|
23
|
+
atomically replace the new-flow configuration.
|
|
24
|
+
|
|
25
|
+
The package installs the short command `feishu-task-agent`. Running that short
|
|
26
|
+
command without arguments shows help. Running the standalone Bootstrap without
|
|
27
|
+
arguments is equivalent to `install`.
|
|
28
|
+
|
|
29
|
+
Only `codex` and `cursor` are supported by this flow. `--agent codex|cursor`
|
|
30
|
+
fixes the Agent for every new binding in that command instead of prompting.
|
|
31
|
+
The Task Agent flow only supports the Online environment. Environment-switch
|
|
32
|
+
arguments are not supported.
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
feishu-task-agent install
|
|
38
|
+
feishu-task-agent start
|
|
39
|
+
feishu-task-agent list
|
|
40
|
+
feishu-task-agent add
|
|
41
|
+
feishu-task-agent remove
|
|
42
|
+
feishu-task-agent update
|
|
43
|
+
feishu-task-agent help
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- `install` collects one or more pairs, then serially pairs and starts them.
|
|
47
|
+
The initial successful Bridge processes remain running.
|
|
48
|
+
- `start` multi-selects saved pairs. Selecting `全部` takes precedence over any
|
|
49
|
+
other selection. If no pair is configured, the terminal prints the full
|
|
50
|
+
install command.
|
|
51
|
+
- `list` prints saved Agent-Bot pairs and never prints App Secrets.
|
|
52
|
+
- `add` completes Bot selection/creation and lark-cli authorization, then saves
|
|
53
|
+
each pair as pending. It does not acquire an Agent lease, perform ACP pairing,
|
|
54
|
+
or start any Bridge. The first subsequent `start` completes pairing, creates
|
|
55
|
+
runtime configuration, and keeps the selected Bridges running.
|
|
56
|
+
- `remove` multi-selects pairs, with a `全部` option. It only removes saved
|
|
57
|
+
pairing records; Bridges that are already running are not stopped.
|
|
58
|
+
- `update` refreshes the installed short command and package.
|
|
59
|
+
|
|
60
|
+
Single-choice and confirmation menus use `↑`/`↓` to move and Enter to confirm.
|
|
61
|
+
For `start` and `remove`, use `↑`/`↓` to move, Space to select multiple items,
|
|
62
|
+
and Enter to confirm the selection.
|
|
63
|
+
|
|
64
|
+
Bridge startup is serial. A failure is printed and recorded, then the next pair
|
|
65
|
+
is attempted. The command exits as a startup failure only when every selected
|
|
66
|
+
pair fails. Within one command invocation, all Bots for the same Agent/AAMP
|
|
67
|
+
host reuse one ACP Bridge process. Independent invocations do not attach to an
|
|
68
|
+
existing process; an Agent lease prevents competing runtimes from being
|
|
69
|
+
started for the same Agent identity.
|
|
70
|
+
|
|
71
|
+
Only `install` and `start` may launch Bridges. Before either command enters its
|
|
72
|
+
interactive flow, it acquires one global runtime-session lease and also checks
|
|
73
|
+
for Agent leases left by an older Task Agent version. If another live Task
|
|
74
|
+
Agent runtime exists, the command exits and asks the user to stop the previous
|
|
75
|
+
terminal with `Ctrl+C`. `add`, `list`, and `remove` never acquire this runtime
|
|
76
|
+
lease and never start a Bridge.
|
|
77
|
+
|
|
78
|
+
## Configuration and compatibility
|
|
79
|
+
|
|
80
|
+
The new flow uses a dedicated configuration:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
~/.aamp/feishu-task-agent/bindings-v1.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
It does not read, migrate, overwrite, or delete the legacy Feishu task profile
|
|
87
|
+
configuration. Existing users enter the new binding flow again. Runtime files
|
|
88
|
+
are isolated under:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
~/.aamp/feishu-task-agent/runtime-v1/
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The configuration directory is mode `0700` and `bindings-v1.json` is mode
|
|
95
|
+
`0600`. The App Secret is intentionally stored as plaintext so a saved pair can
|
|
96
|
+
be started again without asking for credentials. Treat this file as a local
|
|
97
|
+
credential and do not share it. Bridge-specific derived configuration is kept
|
|
98
|
+
inside the same protected new-flow runtime directory.
|
|
99
|
+
|
|
100
|
+
Bindings saved by `add` use the `pending` state and do not contain Agent or
|
|
101
|
+
Feishu Bridge runtime identities. After their first successful `start`, the
|
|
102
|
+
same records are atomically updated to `ready` with the generated runtime
|
|
103
|
+
metadata. Existing ready records without an explicit state remain compatible.
|
|
104
|
+
|
|
105
|
+
Saved bindings whose environment is not Online are incompatible with this
|
|
106
|
+
version. They remain visible to `list` and can be deleted with `remove`, but
|
|
107
|
+
`start` skips them with an explicit error and continues with the next binding.
|
|
108
|
+
|
|
109
|
+
If a saved `lark-cli` profile is missing, `start` recreates it from the stored
|
|
110
|
+
App ID/App Secret and resumes user authorization. If the persisted Agent or
|
|
111
|
+
Feishu Bridge mailbox identity is missing or has changed, startup rejects that
|
|
112
|
+
pair and asks the user to bind it again instead of reporting a false success.
|
|
113
|
+
The same Online `LARKSUITE_CLI_CONFIG_DIR` is passed to Feishu Bridge, Agent
|
|
114
|
+
Bridge, and the local Codex/Cursor process so task execution resolves the exact
|
|
115
|
+
profile created during binding.
|
|
116
|
+
|
|
117
|
+
## Local logs and diagnostics
|
|
118
|
+
|
|
119
|
+
Each command creates a run under:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
~/.aamp/logs/runs/<timestamp>-<pid>/
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Multi-Bot runs use flat, unique component log files so the existing `aamp-logs`
|
|
126
|
+
command can find them:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
one-click.log
|
|
130
|
+
feishu-register.<random>
|
|
131
|
+
acp-bridge-<host-hash>.jsonl
|
|
132
|
+
feishu-bridge-<binding-id>-install.jsonl
|
|
133
|
+
feishu-bridge-<binding-id>-add.jsonl
|
|
134
|
+
feishu-bridge-<binding-id>-start.jsonl
|
|
135
|
+
errors.jsonl
|
|
136
|
+
manifest.json
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The manifest and terminal output never include App Secrets. Component output is
|
|
140
|
+
redacted before it is written by the Controller. Verbose Feishu registration
|
|
141
|
+
details (SDK metadata, scopes, events, and raw SDK output) are written to a
|
|
142
|
+
private per-run registration log instead of flooding the interactive terminal.
|
|
143
|
+
|
|
144
|
+
Useful diagnostics commands:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
~/.aamp/bin/aamp-logs collect --run-dir <run log directory>
|
|
148
|
+
~/.aamp/bin/aamp-logs collect --latest
|
|
149
|
+
~/.aamp/bin/aamp-logs collect --task-id <Task ID>
|
|
150
|
+
~/.aamp/bin/aamp-logs collect --task-guid <Feishu task guid>
|
|
151
|
+
~/.aamp/bin/aamp-logs list-runs
|
|
152
|
+
~/.aamp/bin/aamp-logs tail -f
|
|
153
|
+
```
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
const SENSITIVE_KEY_PATTERN = [
|
|
17
|
+
'secret',
|
|
18
|
+
'token',
|
|
19
|
+
'password',
|
|
20
|
+
'authorization',
|
|
21
|
+
'cookie',
|
|
22
|
+
'credential',
|
|
23
|
+
'appSecret',
|
|
24
|
+
'smtpPassword',
|
|
25
|
+
'mailboxToken',
|
|
26
|
+
'access_token',
|
|
27
|
+
'device_code',
|
|
28
|
+
'user_code',
|
|
29
|
+
'app_secret',
|
|
30
|
+
].join('|')
|
|
31
|
+
|
|
32
|
+
const TEXT_LOG_EXTENSIONS = new Set(['.json', '.jsonl', '.log', '.txt'])
|
|
33
|
+
const FEISHU_TASK_ID_PREFIX = 'feishu-task-'
|
|
34
|
+
|
|
35
|
+
function usage(exitCode = 0) {
|
|
36
|
+
const stream = exitCode === 0 ? process.stdout : process.stderr
|
|
37
|
+
stream.write(`Usage:
|
|
38
|
+
aamp-logs --version
|
|
39
|
+
aamp-logs collect --task-id <task-id>
|
|
40
|
+
aamp-logs collect --task-guid <task-guid>
|
|
41
|
+
aamp-logs collect --run-dir <run-dir>
|
|
42
|
+
aamp-logs collect --latest
|
|
43
|
+
aamp-logs collect --since <duration>
|
|
44
|
+
aamp-logs list-runs
|
|
45
|
+
aamp-logs tail --task-id <task-id>
|
|
46
|
+
aamp-logs tail --task-guid <task-guid>
|
|
47
|
+
aamp-logs tail -f [--task-id <task-id>|--task-guid <task-guid>|<task-id>]
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--log-dir <dir> Override AAMP log root. Defaults to AAMP_LOG_DIR or ~/.aamp/logs.
|
|
51
|
+
--include-content Include full matching run logs instead of matching fragments only.
|
|
52
|
+
-f, --follow Follow new log lines in real time. Without a selector, follows the latest run.
|
|
53
|
+
-h, --help Show this help.
|
|
54
|
+
|
|
55
|
+
Collect creates the local .tar.gz bundle on your Desktop.
|
|
56
|
+
`)
|
|
57
|
+
process.exit(exitCode)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function packageVersion() {
|
|
61
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
62
|
+
if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
|
|
63
|
+
throw new Error('Task-agent package version is unavailable')
|
|
64
|
+
}
|
|
65
|
+
return packageJson.version
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseArgs(argv) {
|
|
69
|
+
const [command, ...rest] = argv
|
|
70
|
+
if (!command || command === '-h' || command === '--help') usage(0)
|
|
71
|
+
|
|
72
|
+
const options = { command }
|
|
73
|
+
const booleanFlags = new Set(['--latest', '--follow'])
|
|
74
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
75
|
+
const arg = rest[i]
|
|
76
|
+
if (arg === '-h' || arg === '--help') usage(0)
|
|
77
|
+
if (arg === '-f') {
|
|
78
|
+
options.follow = true
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (arg === '--include-content' || booleanFlags.has(arg)) {
|
|
82
|
+
options[arg.slice(2).replace(/-/g, '_')] = true
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (arg.startsWith('--')) {
|
|
86
|
+
const value = rest[i + 1]
|
|
87
|
+
if (!value || value.startsWith('--')) {
|
|
88
|
+
throw new Error(`Missing value for ${arg}`)
|
|
89
|
+
}
|
|
90
|
+
options[arg.slice(2).replace(/-/g, '_')] = value
|
|
91
|
+
i += 1
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (command === 'tail' && !options.task_id && !options.task_guid) {
|
|
95
|
+
options.task_id = arg
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`Unknown argument: ${arg}`)
|
|
99
|
+
}
|
|
100
|
+
return options
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function homeLogsDir() {
|
|
104
|
+
return path.join(os.homedir(), '.aamp', 'logs')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveLogRoot(options) {
|
|
108
|
+
return path.resolve(options.log_dir || process.env.AAMP_LOG_DIR || homeLogsDir())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listRunDirs(logRoot) {
|
|
112
|
+
const runsDir = path.join(logRoot, 'runs')
|
|
113
|
+
if (!existsSync(runsDir)) return []
|
|
114
|
+
return readdirSync(runsDir)
|
|
115
|
+
.map((name) => path.join(runsDir, name))
|
|
116
|
+
.filter((entry) => statSync(entry).isDirectory())
|
|
117
|
+
.sort((left, right) => path.basename(left).localeCompare(path.basename(right)))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function latestRun(logRoot) {
|
|
121
|
+
const runs = listRunDirs(logRoot)
|
|
122
|
+
return runs.at(-1)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveRunDir(logRoot, value) {
|
|
126
|
+
const runDir = path.isAbsolute(value)
|
|
127
|
+
? path.resolve(value)
|
|
128
|
+
: path.join(logRoot, 'runs', value)
|
|
129
|
+
if (!existsSync(runDir) || !statSync(runDir).isDirectory()) {
|
|
130
|
+
throw new Error(`Run directory not found: ${value}`)
|
|
131
|
+
}
|
|
132
|
+
return runDir
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseDurationMs(value) {
|
|
136
|
+
const match = /^(\d+)(ms|s|m|h|d)?$/.exec(String(value).trim())
|
|
137
|
+
if (!match) throw new Error(`Invalid duration: ${value}`)
|
|
138
|
+
const amount = Number(match[1])
|
|
139
|
+
const unit = match[2] || 'ms'
|
|
140
|
+
const multipliers = {
|
|
141
|
+
ms: 1,
|
|
142
|
+
s: 1000,
|
|
143
|
+
m: 60 * 1000,
|
|
144
|
+
h: 60 * 60 * 1000,
|
|
145
|
+
d: 24 * 60 * 60 * 1000,
|
|
146
|
+
}
|
|
147
|
+
return amount * multipliers[unit]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function runTimestamp(runDir) {
|
|
151
|
+
const name = path.basename(runDir)
|
|
152
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/.exec(name)
|
|
153
|
+
if (match) {
|
|
154
|
+
const [, year, month, day, hour, minute, second] = match
|
|
155
|
+
return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}`).getTime()
|
|
156
|
+
}
|
|
157
|
+
return statSync(runDir).mtimeMs
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function textFiles(runDir) {
|
|
161
|
+
return readdirSync(runDir)
|
|
162
|
+
.map((name) => path.join(runDir, name))
|
|
163
|
+
.filter((file) => {
|
|
164
|
+
const stat = statSync(file)
|
|
165
|
+
return stat.isFile() && TEXT_LOG_EXTENSIONS.has(path.extname(file))
|
|
166
|
+
})
|
|
167
|
+
.sort((left, right) => path.basename(left).localeCompare(path.basename(right)))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readLines(file) {
|
|
171
|
+
return readFileSync(file, 'utf8').split(/\r?\n/).filter((line) => line.length > 0)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function lineHasAny(line, values) {
|
|
175
|
+
return values.some((value) => value && line.includes(value))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function redactText(input) {
|
|
179
|
+
const key = SENSITIVE_KEY_PATTERN
|
|
180
|
+
return String(input)
|
|
181
|
+
.replace(new RegExp(`("(?:[^"]*(?:${key})[^"]*)"\\s*:\\s*)"[^"]*"`, 'gi'), '$1"<redacted>"')
|
|
182
|
+
.replace(new RegExp(`\\b((?:${key})\\s*=)\\S+`, 'gi'), '$1<redacted>')
|
|
183
|
+
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, '$1 <redacted>')
|
|
184
|
+
.replace(new RegExp(`([?&](?:${key})=)[^&\\s]+`, 'gi'), '$1<redacted>')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseJsonLine(line) {
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(line)
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractTaskIdsFromLine(line, parsed) {
|
|
196
|
+
const taskIds = new Set()
|
|
197
|
+
for (const key of ['task_id', 'taskId', 'aampTaskId', 'aamp_task']) {
|
|
198
|
+
if (typeof parsed?.[key] === 'string' && parsed[key]) {
|
|
199
|
+
taskIds.add(parsed[key])
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const patterns = [
|
|
204
|
+
/"(?:task_id|taskId|aampTaskId|aamp_task)"\s*:\s*"([^"]+)"/g,
|
|
205
|
+
/\b(?:task_id|taskId|aampTaskId|aamp_task)=([A-Za-z0-9._:-]+)/g,
|
|
206
|
+
/\bTask ID:\s*([A-Za-z0-9._:-]+)/g,
|
|
207
|
+
/\b(feishu-task-[A-Za-z0-9._:-]+)/g,
|
|
208
|
+
]
|
|
209
|
+
for (const pattern of patterns) {
|
|
210
|
+
for (const match of line.matchAll(pattern)) {
|
|
211
|
+
if (match[1]) taskIds.add(match[1])
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return [...taskIds]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function taskIdPrefixForGuid(taskGuid) {
|
|
218
|
+
return `${FEISHU_TASK_ID_PREFIX}${taskGuid}-`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function collectTaskIdsForGuid(runDirs, taskGuid) {
|
|
222
|
+
const taskIds = new Set()
|
|
223
|
+
const taskIdPrefix = taskIdPrefixForGuid(taskGuid)
|
|
224
|
+
for (const runDir of runDirs) {
|
|
225
|
+
for (const file of textFiles(runDir)) {
|
|
226
|
+
for (const line of readLines(file)) {
|
|
227
|
+
const parsed = parseJsonLine(line)
|
|
228
|
+
const lineMatchesGuid = parsed?.task_guid === taskGuid
|
|
229
|
+
|| parsed?.taskGuid === taskGuid
|
|
230
|
+
|| line.includes(taskIdPrefix)
|
|
231
|
+
|| line.includes(taskGuid)
|
|
232
|
+
if (lineMatchesGuid) {
|
|
233
|
+
for (const taskId of extractTaskIdsFromLine(line, parsed)) {
|
|
234
|
+
if (taskId.startsWith(taskIdPrefix)) taskIds.add(taskId)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return [...taskIds]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function runContainsAny(runDir, values) {
|
|
244
|
+
return textFiles(runDir).some((file) => readLines(file).some((line) => lineHasAny(line, values)))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function selectRuns(logRoot, options, matchValues) {
|
|
248
|
+
const runs = listRunDirs(logRoot)
|
|
249
|
+
if (options.run_dir) {
|
|
250
|
+
return [resolveRunDir(logRoot, options.run_dir)]
|
|
251
|
+
}
|
|
252
|
+
if (options.latest) {
|
|
253
|
+
const run = latestRun(logRoot)
|
|
254
|
+
return run ? [run] : []
|
|
255
|
+
}
|
|
256
|
+
if (options.since) {
|
|
257
|
+
const cutoff = Date.now() - parseDurationMs(options.since)
|
|
258
|
+
return runs.filter((runDir) => runTimestamp(runDir) >= cutoff)
|
|
259
|
+
}
|
|
260
|
+
if (matchValues.length > 0) {
|
|
261
|
+
return runs.filter((runDir) => runContainsAny(runDir, matchValues))
|
|
262
|
+
}
|
|
263
|
+
return []
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function ensureArchiveDir() {
|
|
267
|
+
const archiveDir = path.join(os.homedir(), 'Desktop')
|
|
268
|
+
mkdirSync(archiveDir, { recursive: true })
|
|
269
|
+
return archiveDir
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function archiveStamp() {
|
|
273
|
+
return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function writeReadme(file, options, runDirs, taskIds) {
|
|
277
|
+
const taskIdPrefix = options.task_guid ? taskIdPrefixForGuid(options.task_guid) : undefined
|
|
278
|
+
writeFileSync(file, [
|
|
279
|
+
'AAMP local logs bundle',
|
|
280
|
+
'',
|
|
281
|
+
`Created at: ${new Date().toISOString()}`,
|
|
282
|
+
`Command: ${options.command}`,
|
|
283
|
+
taskIds.length ? `Task IDs: ${taskIds.join(', ')}` : undefined,
|
|
284
|
+
taskIdPrefix ? `Task ID prefix: ${taskIdPrefix}` : undefined,
|
|
285
|
+
options.task_guid ? `Task GUID: ${options.task_guid}` : undefined,
|
|
286
|
+
options.latest ? 'Selection: latest run' : undefined,
|
|
287
|
+
options.since ? `Selection: since ${options.since}` : undefined,
|
|
288
|
+
options.run_dir ? `Selection: run dir ${options.run_dir}` : undefined,
|
|
289
|
+
`Runs: ${runDirs.map((runDir) => path.basename(runDir)).join(', ') || '(none)'}`,
|
|
290
|
+
'',
|
|
291
|
+
'Sensitive values are redacted before packaging. This bundle is local-only and was not uploaded by aamp-logs.',
|
|
292
|
+
'',
|
|
293
|
+
].filter(Boolean).join('\n'))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function writeBundleManifest(file, options, runDirs, taskIds, matchValues) {
|
|
297
|
+
const manifest = {
|
|
298
|
+
schema: 'aamp.local_logs.bundle.v1',
|
|
299
|
+
created_at: new Date().toISOString(),
|
|
300
|
+
command: options.command,
|
|
301
|
+
selection: {
|
|
302
|
+
...(options.task_id ? { task_id: options.task_id } : {}),
|
|
303
|
+
...(options.task_guid ? {
|
|
304
|
+
task_guid: options.task_guid,
|
|
305
|
+
task_id_prefix: taskIdPrefixForGuid(options.task_guid),
|
|
306
|
+
} : {}),
|
|
307
|
+
...(options.latest ? { latest: true } : {}),
|
|
308
|
+
...(options.since ? { since: options.since } : {}),
|
|
309
|
+
...(options.run_dir ? { run_dir: options.run_dir } : {}),
|
|
310
|
+
},
|
|
311
|
+
task_ids: taskIds,
|
|
312
|
+
match_values: matchValues,
|
|
313
|
+
runs: runDirs.map((runDir) => path.basename(runDir)),
|
|
314
|
+
redaction: {
|
|
315
|
+
enabled: true,
|
|
316
|
+
include_content: Boolean(options.include_content),
|
|
317
|
+
},
|
|
318
|
+
local_only: true,
|
|
319
|
+
}
|
|
320
|
+
writeFileSync(file, `${redactText(JSON.stringify(manifest, null, 2))}\n`)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function copyFilteredRun(runDir, targetDir, matchValues, includeWholeRun) {
|
|
324
|
+
mkdirSync(targetDir, { recursive: true })
|
|
325
|
+
for (const file of textFiles(runDir)) {
|
|
326
|
+
const relativeName = path.basename(file)
|
|
327
|
+
const target = path.join(targetDir, relativeName)
|
|
328
|
+
if (relativeName === 'manifest.json') {
|
|
329
|
+
writeFileSync(target, redactText(readFileSync(file, 'utf8')))
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (includeWholeRun) {
|
|
334
|
+
writeFileSync(target, redactText(readFileSync(file, 'utf8')))
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const matching = readLines(file).filter((line) => lineHasAny(line, matchValues))
|
|
339
|
+
if (matching.length > 0) {
|
|
340
|
+
writeFileSync(target, `${matching.map(redactText).join('\n')}\n`)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function createArchive(options, runDirs, taskIds, matchValues) {
|
|
346
|
+
if (runDirs.length === 0) {
|
|
347
|
+
throw new Error('No matching log runs found')
|
|
348
|
+
}
|
|
349
|
+
const stageRoot = mkdtempSync(path.join(os.tmpdir(), 'aamp-logs-bundle-'))
|
|
350
|
+
const bundleName = `aamp-logs-${archiveStamp()}`
|
|
351
|
+
const bundleDir = path.join(stageRoot, bundleName)
|
|
352
|
+
mkdirSync(path.join(bundleDir, 'runs'), { recursive: true })
|
|
353
|
+
|
|
354
|
+
const includeWholeRun = Boolean(options.include_content) || (taskIds.length === 0 && !options.task_guid)
|
|
355
|
+
|
|
356
|
+
for (const runDir of runDirs) {
|
|
357
|
+
copyFilteredRun(runDir, path.join(bundleDir, 'runs', path.basename(runDir)), matchValues, includeWholeRun)
|
|
358
|
+
}
|
|
359
|
+
writeReadme(path.join(bundleDir, 'README.txt'), options, runDirs, taskIds)
|
|
360
|
+
writeBundleManifest(path.join(bundleDir, 'manifest.json'), options, runDirs, taskIds, matchValues)
|
|
361
|
+
|
|
362
|
+
const archiveDir = ensureArchiveDir()
|
|
363
|
+
const archivePath = path.join(archiveDir, `${bundleName}.tar.gz`)
|
|
364
|
+
execFileSync('tar', ['-czf', archivePath, '-C', stageRoot, bundleName])
|
|
365
|
+
rmSync(stageRoot, { recursive: true, force: true })
|
|
366
|
+
return archivePath
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function collect(options) {
|
|
370
|
+
const logRoot = resolveLogRoot(options)
|
|
371
|
+
let taskIds = []
|
|
372
|
+
if (options.task_id) taskIds = [options.task_id]
|
|
373
|
+
let matchValues = [...taskIds]
|
|
374
|
+
if (options.task_guid) {
|
|
375
|
+
taskIds = collectTaskIdsForGuid(listRunDirs(logRoot), options.task_guid)
|
|
376
|
+
matchValues = [
|
|
377
|
+
taskIdPrefixForGuid(options.task_guid),
|
|
378
|
+
options.task_guid,
|
|
379
|
+
...taskIds,
|
|
380
|
+
]
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!options.task_id && !options.task_guid && !options.run_dir && !options.latest && !options.since) {
|
|
384
|
+
throw new Error('collect requires --task-id, --task-guid, --run-dir, --latest, or --since')
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const runDirs = selectRuns(logRoot, options, matchValues)
|
|
388
|
+
const archivePath = createArchive(options, runDirs, taskIds, matchValues)
|
|
389
|
+
process.stdout.write(`Created local logs bundle:\n${archivePath}\n`)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function listRuns(options) {
|
|
393
|
+
const logRoot = resolveLogRoot(options)
|
|
394
|
+
const runs = listRunDirs(logRoot)
|
|
395
|
+
process.stdout.write(`${runs.map((runDir) => path.basename(runDir)).join('\n')}${runs.length ? '\n' : ''}`)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function tailMatchValues(logRoot, options) {
|
|
399
|
+
if (options.task_id && options.task_guid) {
|
|
400
|
+
throw new Error('tail accepts only one of --task-id or --task-guid')
|
|
401
|
+
}
|
|
402
|
+
if (options.task_id) return [options.task_id]
|
|
403
|
+
if (options.task_guid) {
|
|
404
|
+
return [
|
|
405
|
+
taskIdPrefixForGuid(options.task_guid),
|
|
406
|
+
options.task_guid,
|
|
407
|
+
...collectTaskIdsForGuid(listRunDirs(logRoot), options.task_guid),
|
|
408
|
+
]
|
|
409
|
+
}
|
|
410
|
+
return []
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function formatTailLine(runDir, file, line) {
|
|
414
|
+
return `${path.basename(runDir)}/${path.basename(file)}: ${redactText(line)}`
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function writeMatchingTailLines(runDirs, matchValues) {
|
|
418
|
+
const lines = []
|
|
419
|
+
for (const runDir of runDirs) {
|
|
420
|
+
for (const file of textFiles(runDir)) {
|
|
421
|
+
for (const line of readLines(file)) {
|
|
422
|
+
if (matchValues.length === 0 || lineHasAny(line, matchValues)) {
|
|
423
|
+
lines.push(formatTailLine(runDir, file, line))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
process.stdout.write(`${lines.join('\n')}${lines.length ? '\n' : ''}`)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function selectFollowRunDirs(logRoot, matchValues) {
|
|
432
|
+
if (matchValues.length > 0) {
|
|
433
|
+
const matching = selectRuns(logRoot, {}, matchValues)
|
|
434
|
+
if (matching.length > 0) return matching
|
|
435
|
+
}
|
|
436
|
+
const run = latestRun(logRoot)
|
|
437
|
+
return run ? [run] : []
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function followTailFiles(runDirs, matchValues) {
|
|
441
|
+
const files = runDirs.flatMap((runDir) => textFiles(runDir).map((file) => ({ runDir, file })))
|
|
442
|
+
if (files.length === 0) throw new Error('No log files found to follow')
|
|
443
|
+
|
|
444
|
+
const children = []
|
|
445
|
+
let liveChildren = files.length
|
|
446
|
+
const stop = (code = 0) => {
|
|
447
|
+
for (const child of children) {
|
|
448
|
+
if (!child.killed) child.kill()
|
|
449
|
+
}
|
|
450
|
+
process.exit(code)
|
|
451
|
+
}
|
|
452
|
+
process.on('SIGINT', () => stop(0))
|
|
453
|
+
process.on('SIGTERM', () => stop(0))
|
|
454
|
+
|
|
455
|
+
for (const { runDir, file } of files) {
|
|
456
|
+
const child = spawn('tail', ['-n', '0', '-f', file], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
457
|
+
children.push(child)
|
|
458
|
+
|
|
459
|
+
let buffer = ''
|
|
460
|
+
child.stdout.setEncoding('utf8')
|
|
461
|
+
child.stdout.on('data', (chunk) => {
|
|
462
|
+
buffer += chunk
|
|
463
|
+
const lines = buffer.split(/\r?\n/)
|
|
464
|
+
buffer = lines.pop() ?? ''
|
|
465
|
+
for (const line of lines) {
|
|
466
|
+
if (line.length === 0) continue
|
|
467
|
+
if (matchValues.length > 0 && !lineHasAny(line, matchValues)) continue
|
|
468
|
+
process.stdout.write(`${formatTailLine(runDir, file, line)}\n`)
|
|
469
|
+
}
|
|
470
|
+
})
|
|
471
|
+
child.stderr.setEncoding('utf8')
|
|
472
|
+
child.stderr.on('data', (chunk) => {
|
|
473
|
+
process.stderr.write(chunk)
|
|
474
|
+
})
|
|
475
|
+
child.on('exit', () => {
|
|
476
|
+
liveChildren -= 1
|
|
477
|
+
if (liveChildren === 0) process.exit(0)
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function tail(options) {
|
|
483
|
+
const logRoot = resolveLogRoot(options)
|
|
484
|
+
const matchValues = tailMatchValues(logRoot, options)
|
|
485
|
+
if (options.follow) {
|
|
486
|
+
const runDirs = selectFollowRunDirs(logRoot, matchValues)
|
|
487
|
+
if (runDirs.length === 0) throw new Error('No log runs found to follow')
|
|
488
|
+
if (matchValues.length > 0) writeMatchingTailLines(runDirs, matchValues)
|
|
489
|
+
followTailFiles(runDirs, matchValues)
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
if (matchValues.length === 0) throw new Error('tail requires --task-id, --task-guid, or -f')
|
|
493
|
+
writeMatchingTailLines(listRunDirs(logRoot), matchValues)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function main() {
|
|
497
|
+
const args = process.argv.slice(2)
|
|
498
|
+
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
|
|
499
|
+
process.stdout.write(`${packageVersion()}\n`)
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
const options = parseArgs(args)
|
|
503
|
+
if (options.command === 'collect') {
|
|
504
|
+
collect(options)
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
if (options.command === 'list-runs') {
|
|
508
|
+
listRuns(options)
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
if (options.command === 'tail') {
|
|
512
|
+
tail(options)
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
throw new Error(`Unknown command: ${options.command}`)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
main()
|
|
520
|
+
} catch (error) {
|
|
521
|
+
process.stderr.write(`aamp-logs: ${(error instanceof Error ? error.message : String(error))}\n`)
|
|
522
|
+
process.exit(1)
|
|
523
|
+
}
|