@opsrev/acculynx-cli 1.0.0 → 1.2.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/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/index.js +597 -15
- package/package.json +5 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpsRev.ai
|
|
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
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# @opsrev/acculynx-cli
|
|
2
|
+
|
|
3
|
+
[](https://github.com/opsrev/acculynx-cli/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
CLI for the AccuLynx roofing CRM API. Designed for AI agent consumption -- all output is JSON.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @opsrev/acculynx-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Copy the example env file and fill in your credentials:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cp .env.example .env
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Your `.env` is gitignored and will never be committed.
|
|
22
|
+
|
|
23
|
+
### Official API
|
|
24
|
+
|
|
25
|
+
| Env Var | Flag | Description |
|
|
26
|
+
|---------|------|-------------|
|
|
27
|
+
| `ACCULYNX_API_KEY` | `--api-key` | AccuLynx API key (required) |
|
|
28
|
+
|
|
29
|
+
API keys are created by your AccuLynx account administrator under Account Settings.
|
|
30
|
+
|
|
31
|
+
### Unofficial Web API
|
|
32
|
+
|
|
33
|
+
The `unofficial` commands use cookie-based authentication against AccuLynx's internal web APIs. These provide access to features not available in the official API (job documents, messages, etc.).
|
|
34
|
+
|
|
35
|
+
| Env Var | Flag | Description |
|
|
36
|
+
|---------|------|-------------|
|
|
37
|
+
| `ACCULYNX_EMAIL` | `--email` | Your AccuLynx login email |
|
|
38
|
+
| `ACCULYNX_PASSWORD` | `--password` | Your AccuLynx password |
|
|
39
|
+
| `ACCULYNX_COMPANY_ID` | `--company-id` | Company GUID from location selector |
|
|
40
|
+
|
|
41
|
+
> **Note:** These endpoints are reverse-engineered from the AccuLynx web app and may break without notice.
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
acculynx ping # Health check / verify API key
|
|
47
|
+
acculynx jobs list [--limit <n>] [--all] # List jobs (default: 25 results)
|
|
48
|
+
acculynx jobs get <jobId> # Get job details
|
|
49
|
+
acculynx jobs create # Create a job (JSON from stdin)
|
|
50
|
+
acculynx jobs search --query <text> # Search jobs
|
|
51
|
+
acculynx jobs contacts <jobId> # List job contacts
|
|
52
|
+
acculynx jobs estimates <jobId> # List job estimates
|
|
53
|
+
acculynx jobs financials <jobId> # Get job financials
|
|
54
|
+
acculynx jobs invoices <jobId> # List job invoices
|
|
55
|
+
acculynx jobs milestones <jobId> # List job milestone history
|
|
56
|
+
acculynx jobs payments <jobId> # List job payments
|
|
57
|
+
acculynx jobs history <jobId> # Get job change history
|
|
58
|
+
acculynx contacts list [--limit <n>] [--all] # List contacts (default: 25 results)
|
|
59
|
+
acculynx contacts get <contactId> # Get contact details
|
|
60
|
+
acculynx contacts create # Create a contact (JSON from stdin)
|
|
61
|
+
acculynx contacts search --query <text> # Search contacts
|
|
62
|
+
acculynx contacts emails <contactId> # List contact email addresses
|
|
63
|
+
acculynx contacts phones <contactId> # List contact phone numbers
|
|
64
|
+
acculynx estimates list [--limit <n>] [--all] # List estimates (default: 25 results)
|
|
65
|
+
acculynx estimates get <estimateId> # Get estimate details
|
|
66
|
+
acculynx estimates sections <estimateId> # List sections for an estimate
|
|
67
|
+
acculynx estimates section <estimateId> <secId> # Get section details
|
|
68
|
+
acculynx estimates items <estimateId> <secId> # List items in a section
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Unofficial commands
|
|
72
|
+
|
|
73
|
+
Requires `ACCULYNX_EMAIL`, `ACCULYNX_PASSWORD`, and `ACCULYNX_COMPANY_ID` in your `.env` (or passed as flags).
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
acculynx unofficial companies # List available companies (discover your company ID)
|
|
77
|
+
acculynx unofficial login # Authenticate and cache session
|
|
78
|
+
acculynx unofficial logout # Remove cached session
|
|
79
|
+
acculynx unofficial sessions # List cached sessions
|
|
80
|
+
acculynx unofficial documents list <jobId> # List all document folders/files for a job
|
|
81
|
+
acculynx unofficial documents download <jobId> <fileId> [--output ./file.pdf] # Download a document
|
|
82
|
+
acculynx unofficial messages list <jobId> # List all messages/comments for a job
|
|
83
|
+
acculynx unofficial messages list <jobId> --type Email # Filter by type: Comment, Email, or Signatures
|
|
84
|
+
acculynx unofficial messages list <jobId> --count-only # Get message count only
|
|
85
|
+
acculynx unofficial messages post <jobId> <message> # Post a comment to a job
|
|
86
|
+
acculynx unofficial messages post <jobId> <message> --notify <userId,...> # Post and notify users
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Messages
|
|
90
|
+
|
|
91
|
+
List all messages, comments, and emails on a job. HTML email content is automatically converted to lightweight markdown (headers, links, lists preserved; images and styles stripped) for clean AI agent consumption.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
acculynx unofficial messages list <jobId> # all messages, newest first
|
|
95
|
+
acculynx unofficial messages list <jobId> --sort "createdDate|asc" # oldest first
|
|
96
|
+
acculynx unofficial messages list <jobId> --type Email # only emails
|
|
97
|
+
acculynx unofficial messages list <jobId> --count-only # just the count
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Post a comment to a job, optionally notifying (tagging) users:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
acculynx unofficial messages post <jobId> "Your message here"
|
|
104
|
+
acculynx unofficial messages post <jobId> "Check this out" --notify "userId1,userId2"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
User IDs for `--notify` come from the official users API (`/api/v2/users`).
|
|
108
|
+
|
|
109
|
+
**Getting started:** If you don't know your company ID, run `companies` first -- it logs in and returns all companies available to your account:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm run dev -- unofficial companies
|
|
113
|
+
# [{"id":"f104ba56-...","name":"My Roofing Co"},{"id":"7749e90f-...","name":"Sandbox"}]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Copy the `id` into your `.env` as `ACCULYNX_COMPANY_ID`, then run `login` to cache the session.
|
|
117
|
+
|
|
118
|
+
Login caches session cookies to `~/.config/acculynx-cli/sessions/` so you only need to re-authenticate when the session expires. Multiple accounts/companies are supported -- each gets its own cached session.
|
|
119
|
+
|
|
120
|
+
### Pagination
|
|
121
|
+
|
|
122
|
+
All list commands default to **25 results**. Use `--limit <n>` for a custom count or `--all` to fetch everything:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
acculynx jobs list # 25 results (default)
|
|
126
|
+
acculynx jobs list --limit 100 # 100 results
|
|
127
|
+
acculynx jobs list --all # all results
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Jobs list filters
|
|
131
|
+
|
|
132
|
+
- `--start-date <YYYY-MM-DD>` -- filter by start date
|
|
133
|
+
- `--end-date <YYYY-MM-DD>` -- filter by end date
|
|
134
|
+
- `--date-filter-type <type>` -- date field to filter on (default: CreatedDate)
|
|
135
|
+
- `--milestones <milestones>` -- filter by milestones
|
|
136
|
+
- `--sort-by <field>` -- CreatedDate, MilestoneDate, or ModifiedDate
|
|
137
|
+
- `--sort-order <order>` -- Ascending or Descending
|
|
138
|
+
- `--includes <fields>` -- contact, initialAppointment
|
|
139
|
+
|
|
140
|
+
### Search
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
acculynx jobs search --query "smith"
|
|
144
|
+
acculynx contacts search --query "smith"
|
|
145
|
+
acculynx contacts search --query "smith" --sort-by LastName --sort-order Descending
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Stdin commands
|
|
149
|
+
|
|
150
|
+
Commands that create resources accept JSON piped via stdin:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
echo '{"name": "New Job"}' | acculynx jobs create
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Output
|
|
157
|
+
|
|
158
|
+
All output is JSON to stdout. Errors are `{"error": "..."}` to stderr with exit code 1.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
# Pipe to jq for pretty-printing
|
|
162
|
+
acculynx jobs list --limit 5 | jq .
|
|
163
|
+
|
|
164
|
+
# Use in scripts
|
|
165
|
+
JOB_ID=$(acculynx jobs list --limit 1 | jq -r '.[0].id')
|
|
166
|
+
acculynx jobs get "$JOB_ID" | jq .
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
cp .env.example .env # fill in your credentials
|
|
173
|
+
npm install
|
|
174
|
+
npm test # run unit tests
|
|
175
|
+
npm run dev -- --help # run in dev mode (auto-loads .env)
|
|
176
|
+
npm run build # build to dist/
|
|
177
|
+
./test-integration.sh # run integration tests (requires ACCULYNX_API_KEY)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The `npm run dev` script automatically loads your `.env` file via Node's `--env-file` flag (Node 20.6+). Examples:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npm run dev -- unofficial login
|
|
184
|
+
npm run dev -- unofficial documents list <jobId>
|
|
185
|
+
npm run dev -- jobs list --limit 5
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -84,7 +84,8 @@ function registerPingCommand(parentCmd, getClient2) {
|
|
|
84
84
|
|
|
85
85
|
// src/api-helpers.ts
|
|
86
86
|
var DEFAULT_PAGE_SIZE = 25;
|
|
87
|
-
|
|
87
|
+
var DEFAULT_LIMIT = 25;
|
|
88
|
+
async function paginate(client, path, params, limit = DEFAULT_LIMIT) {
|
|
88
89
|
const results = [];
|
|
89
90
|
let pageStartIndex = 0;
|
|
90
91
|
while (true) {
|
|
@@ -95,7 +96,7 @@ async function paginate(client, path, params, limit) {
|
|
|
95
96
|
};
|
|
96
97
|
const data = await client.get(path, pageParams);
|
|
97
98
|
results.push(...data.items);
|
|
98
|
-
if (limit && results.length >= limit) {
|
|
99
|
+
if (limit !== Infinity && results.length >= limit) {
|
|
99
100
|
return results.slice(0, limit);
|
|
100
101
|
}
|
|
101
102
|
if (data.items.length < DEFAULT_PAGE_SIZE) {
|
|
@@ -126,7 +127,7 @@ async function readStdin(stream = process.stdin) {
|
|
|
126
127
|
// src/commands/jobs.ts
|
|
127
128
|
function registerJobsCommands(parentCmd, getClient2) {
|
|
128
129
|
const jobs = parentCmd.command("jobs").description("Job operations");
|
|
129
|
-
jobs.command("list").description("List jobs (paginated)").option("--start-date <date>", "Start date (YYYY-MM-DD)").option("--end-date <date>", "End date (YYYY-MM-DD)").option("--date-filter-type <type>", "Date field to filter on").option("--milestones <milestones>", "Filter by milestones").option("--sort-by <field>", "Sort by: CreatedDate, MilestoneDate, ModifiedDate").option("--sort-order <order>", "Ascending or Descending").option("--includes <fields>", "Include: contact, initialAppointment").option("--limit <n>", "Max total results").action(async (opts) => {
|
|
130
|
+
jobs.command("list").description("List jobs (paginated)").option("--start-date <date>", "Start date (YYYY-MM-DD)").option("--end-date <date>", "End date (YYYY-MM-DD)").option("--date-filter-type <type>", "Date field to filter on").option("--milestones <milestones>", "Filter by milestones").option("--sort-by <field>", "Sort by: CreatedDate, MilestoneDate, ModifiedDate").option("--sort-order <order>", "Ascending or Descending").option("--includes <fields>", "Include: contact, initialAppointment").option("--limit <n>", "Max total results (default: 25)").option("--all", "Fetch all results (no limit)").action(async (opts) => {
|
|
130
131
|
const params = {};
|
|
131
132
|
if (opts.startDate) params.startDate = opts.startDate;
|
|
132
133
|
if (opts.endDate) params.endDate = opts.endDate;
|
|
@@ -135,7 +136,7 @@ function registerJobsCommands(parentCmd, getClient2) {
|
|
|
135
136
|
if (opts.sortBy) params.sortBy = opts.sortBy;
|
|
136
137
|
if (opts.sortOrder) params.sortOrder = opts.sortOrder;
|
|
137
138
|
if (opts.includes) params.includes = opts.includes;
|
|
138
|
-
const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
|
|
139
|
+
const limit = opts.all ? Infinity : opts.limit ? parseInt(opts.limit, 10) : void 0;
|
|
139
140
|
const result = await paginate(getClient2(), "/jobs", params, limit);
|
|
140
141
|
console.log(JSON.stringify(result));
|
|
141
142
|
});
|
|
@@ -148,9 +149,10 @@ function registerJobsCommands(parentCmd, getClient2) {
|
|
|
148
149
|
const result = await getClient2().post("/jobs", body);
|
|
149
150
|
console.log(JSON.stringify(result));
|
|
150
151
|
});
|
|
151
|
-
jobs.command("search").description("Search jobs
|
|
152
|
-
const
|
|
153
|
-
|
|
152
|
+
jobs.command("search").description("Search jobs").requiredOption("--query <text>", "Search term (required)").action(async (opts) => {
|
|
153
|
+
const result = await getClient2().post("/jobs/search", {
|
|
154
|
+
SearchTerm: opts.query
|
|
155
|
+
});
|
|
154
156
|
console.log(JSON.stringify(result));
|
|
155
157
|
});
|
|
156
158
|
const subResources = [
|
|
@@ -173,8 +175,8 @@ function registerJobsCommands(parentCmd, getClient2) {
|
|
|
173
175
|
// src/commands/contacts.ts
|
|
174
176
|
function registerContactsCommands(parentCmd, getClient2) {
|
|
175
177
|
const contacts = parentCmd.command("contacts").description("Contact operations");
|
|
176
|
-
contacts.command("list").description("List contacts (paginated)").option("--limit <n>", "Max total results").action(async (opts) => {
|
|
177
|
-
const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
|
|
178
|
+
contacts.command("list").description("List contacts (paginated)").option("--limit <n>", "Max total results (default: 25)").option("--all", "Fetch all results (no limit)").action(async (opts) => {
|
|
179
|
+
const limit = opts.all ? Infinity : opts.limit ? parseInt(opts.limit, 10) : void 0;
|
|
178
180
|
const result = await paginate(getClient2(), "/contacts", {}, limit);
|
|
179
181
|
console.log(JSON.stringify(result));
|
|
180
182
|
});
|
|
@@ -187,9 +189,12 @@ function registerContactsCommands(parentCmd, getClient2) {
|
|
|
187
189
|
const result = await getClient2().post("/contacts", body);
|
|
188
190
|
console.log(JSON.stringify(result));
|
|
189
191
|
});
|
|
190
|
-
contacts.command("search").description("Search contacts (
|
|
191
|
-
const
|
|
192
|
-
|
|
192
|
+
contacts.command("search").description("Search contacts").requiredOption("--query <text>", "Search term (required)").option("--sort-by <field>", "Sort by field (default: LastName)", "LastName").option("--sort-order <order>", "Ascending or Descending (default: Ascending)", "Ascending").action(async (opts) => {
|
|
193
|
+
const result = await getClient2().post("/contacts/search", {
|
|
194
|
+
SearchTerm: opts.query,
|
|
195
|
+
SortBy: opts.sortBy,
|
|
196
|
+
SortOrder: opts.sortOrder
|
|
197
|
+
});
|
|
193
198
|
console.log(JSON.stringify(result));
|
|
194
199
|
});
|
|
195
200
|
contacts.command("emails").argument("<contactId>", "Contact ID").description("List contact email addresses").action(async (contactId) => {
|
|
@@ -205,8 +210,8 @@ function registerContactsCommands(parentCmd, getClient2) {
|
|
|
205
210
|
// src/commands/estimates.ts
|
|
206
211
|
function registerEstimatesCommands(parentCmd, getClient2) {
|
|
207
212
|
const estimates = parentCmd.command("estimates").description("Estimate operations");
|
|
208
|
-
estimates.command("list").description("List estimates (paginated)").option("--limit <n>", "Max total results").action(async (opts) => {
|
|
209
|
-
const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
|
|
213
|
+
estimates.command("list").description("List estimates (paginated)").option("--limit <n>", "Max total results (default: 25)").option("--all", "Fetch all results (no limit)").action(async (opts) => {
|
|
214
|
+
const limit = opts.all ? Infinity : opts.limit ? parseInt(opts.limit, 10) : void 0;
|
|
210
215
|
const result = await paginate(getClient2(), "/estimates", {}, limit);
|
|
211
216
|
console.log(JSON.stringify(result));
|
|
212
217
|
});
|
|
@@ -228,11 +233,584 @@ function registerEstimatesCommands(parentCmd, getClient2) {
|
|
|
228
233
|
});
|
|
229
234
|
}
|
|
230
235
|
|
|
236
|
+
// src/unofficial/session-store.ts
|
|
237
|
+
import {
|
|
238
|
+
existsSync,
|
|
239
|
+
readFileSync,
|
|
240
|
+
writeFileSync,
|
|
241
|
+
mkdirSync,
|
|
242
|
+
readdirSync,
|
|
243
|
+
unlinkSync
|
|
244
|
+
} from "fs";
|
|
245
|
+
import { join } from "path";
|
|
246
|
+
import { homedir } from "os";
|
|
247
|
+
|
|
248
|
+
// src/unofficial/cookie-jar.ts
|
|
249
|
+
var CookieJar = class _CookieJar {
|
|
250
|
+
cookies = /* @__PURE__ */ new Map();
|
|
251
|
+
/** Extract and store cookies from a response's Set-Cookie headers. */
|
|
252
|
+
capture(url, response) {
|
|
253
|
+
const domain = new URL(url).hostname;
|
|
254
|
+
const jar = this.cookies.get(domain) ?? /* @__PURE__ */ new Map();
|
|
255
|
+
for (const header of response.headers.getSetCookie()) {
|
|
256
|
+
const [pair] = header.split(";");
|
|
257
|
+
const eqIdx = pair.indexOf("=");
|
|
258
|
+
if (eqIdx === -1) continue;
|
|
259
|
+
jar.set(pair.slice(0, eqIdx).trim(), pair.slice(eqIdx + 1).trim());
|
|
260
|
+
}
|
|
261
|
+
this.cookies.set(domain, jar);
|
|
262
|
+
}
|
|
263
|
+
/** Get a specific cookie value by name for a given URL. */
|
|
264
|
+
getCookie(url, name) {
|
|
265
|
+
const domain = new URL(url).hostname;
|
|
266
|
+
for (const [d, jar] of this.cookies) {
|
|
267
|
+
if (domain === d || domain.endsWith("." + d)) {
|
|
268
|
+
if (jar.has(name)) return jar.get(name);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return void 0;
|
|
272
|
+
}
|
|
273
|
+
/** Build a Cookie header string for a given URL. */
|
|
274
|
+
headerFor(url) {
|
|
275
|
+
const domain = new URL(url).hostname;
|
|
276
|
+
const parts = [];
|
|
277
|
+
for (const [d, jar] of this.cookies) {
|
|
278
|
+
if (domain === d || domain.endsWith("." + d)) {
|
|
279
|
+
for (const [k, v] of jar) parts.push(`${k}=${v}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return parts.join("; ");
|
|
283
|
+
}
|
|
284
|
+
/** Serialize for disk caching. */
|
|
285
|
+
toJSON() {
|
|
286
|
+
const out = {};
|
|
287
|
+
for (const [d, jar] of this.cookies) {
|
|
288
|
+
out[d] = Object.fromEntries(jar);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
/** Restore from disk cache. */
|
|
293
|
+
static fromJSON(data) {
|
|
294
|
+
const jar = new _CookieJar();
|
|
295
|
+
for (const [d, pairs] of Object.entries(data)) {
|
|
296
|
+
jar.cookies.set(d, new Map(Object.entries(pairs)));
|
|
297
|
+
}
|
|
298
|
+
return jar;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/unofficial/session-store.ts
|
|
303
|
+
var CONFIG_DIR = join(
|
|
304
|
+
process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
|
|
305
|
+
"acculynx-cli"
|
|
306
|
+
);
|
|
307
|
+
var SESSIONS_DIR = join(CONFIG_DIR, "sessions");
|
|
308
|
+
function sessionKey(email, companyId) {
|
|
309
|
+
const slug = email.replace(/[@.]/g, "-").toLowerCase();
|
|
310
|
+
const prefix = companyId.slice(0, 8);
|
|
311
|
+
return `${slug}--${prefix}.json`;
|
|
312
|
+
}
|
|
313
|
+
function loadSession(email, companyId) {
|
|
314
|
+
const file = join(SESSIONS_DIR, sessionKey(email, companyId));
|
|
315
|
+
if (!existsSync(file)) return null;
|
|
316
|
+
try {
|
|
317
|
+
const data = JSON.parse(readFileSync(file, "utf-8"));
|
|
318
|
+
return {
|
|
319
|
+
jar: CookieJar.fromJSON(data.cookies),
|
|
320
|
+
companyId: data.companyId,
|
|
321
|
+
email: data.email
|
|
322
|
+
};
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function saveSession(session) {
|
|
328
|
+
mkdirSync(SESSIONS_DIR, { recursive: true, mode: 448 });
|
|
329
|
+
writeFileSync(
|
|
330
|
+
join(SESSIONS_DIR, sessionKey(session.email, session.companyId)),
|
|
331
|
+
JSON.stringify(
|
|
332
|
+
{
|
|
333
|
+
email: session.email,
|
|
334
|
+
companyId: session.companyId,
|
|
335
|
+
cookies: session.jar.toJSON()
|
|
336
|
+
},
|
|
337
|
+
null,
|
|
338
|
+
2
|
|
339
|
+
),
|
|
340
|
+
{ mode: 384 }
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
function deleteSession(email, companyId) {
|
|
344
|
+
const file = join(SESSIONS_DIR, sessionKey(email, companyId));
|
|
345
|
+
if (!existsSync(file)) return false;
|
|
346
|
+
unlinkSync(file);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
function listSessions() {
|
|
350
|
+
if (!existsSync(SESSIONS_DIR)) return [];
|
|
351
|
+
return readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json")).map((f) => {
|
|
352
|
+
try {
|
|
353
|
+
const data = JSON.parse(
|
|
354
|
+
readFileSync(join(SESSIONS_DIR, f), "utf-8")
|
|
355
|
+
);
|
|
356
|
+
return { email: data.email, companyId: data.companyId, file: f };
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}).filter(Boolean);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/unofficial/unofficial-client.ts
|
|
364
|
+
function createUnofficialClient(session) {
|
|
365
|
+
const base = "https://my.acculynx.com";
|
|
366
|
+
async function doGet(path) {
|
|
367
|
+
const url = `${base}${path}`;
|
|
368
|
+
const resp = await fetch(url, {
|
|
369
|
+
headers: {
|
|
370
|
+
Cookie: session.jar.headerFor(url),
|
|
371
|
+
Accept: "application/json"
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
if (resp.status === 401 || resp.status === 302) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
"Session expired \u2014 run `acculynx unofficial login` to re-authenticate"
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (!resp.ok) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Unofficial API error (${resp.status}): ${await resp.text()}`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return resp.json();
|
|
385
|
+
}
|
|
386
|
+
async function doPost(path, body) {
|
|
387
|
+
const url = `${base}${path}`;
|
|
388
|
+
const headers = {
|
|
389
|
+
Cookie: session.jar.headerFor(url),
|
|
390
|
+
Accept: "application/json",
|
|
391
|
+
"Content-Type": "application/json"
|
|
392
|
+
};
|
|
393
|
+
const xsrf = session.jar.getCookie(url, "XSRF-TOKEN");
|
|
394
|
+
if (xsrf) {
|
|
395
|
+
headers["X-XSRF-TOKEN"] = xsrf;
|
|
396
|
+
}
|
|
397
|
+
const resp = await fetch(url, {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers,
|
|
400
|
+
body: JSON.stringify(body)
|
|
401
|
+
});
|
|
402
|
+
if (resp.status === 401 || resp.status === 302) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
"Session expired \u2014 run `acculynx unofficial login` to re-authenticate"
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (!resp.ok) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Unofficial API error (${resp.status}): ${await resp.text()}`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
const text = await resp.text();
|
|
413
|
+
if (!text) return {};
|
|
414
|
+
return JSON.parse(text);
|
|
415
|
+
}
|
|
416
|
+
async function doGetBuffer(url) {
|
|
417
|
+
const fullUrl = url.startsWith("http") ? url : `${base}${url}`;
|
|
418
|
+
const resp = await fetch(fullUrl, {
|
|
419
|
+
headers: { Cookie: session.jar.headerFor(fullUrl) }
|
|
420
|
+
});
|
|
421
|
+
if (!resp.ok) {
|
|
422
|
+
throw new Error(`Download failed (${resp.status}): ${fullUrl}`);
|
|
423
|
+
}
|
|
424
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
425
|
+
}
|
|
426
|
+
return { get: doGet, post: doPost, getBuffer: doGetBuffer };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/unofficial/session.ts
|
|
430
|
+
async function fetchFollowingCookies(jar, url, init) {
|
|
431
|
+
let currentUrl = url;
|
|
432
|
+
let response;
|
|
433
|
+
for (let i = 0; i < 10; i++) {
|
|
434
|
+
response = await fetch(currentUrl, {
|
|
435
|
+
...init,
|
|
436
|
+
redirect: "manual",
|
|
437
|
+
headers: {
|
|
438
|
+
...Object.fromEntries(
|
|
439
|
+
Object.entries(init?.headers ?? {})
|
|
440
|
+
),
|
|
441
|
+
Cookie: jar.headerFor(currentUrl)
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
jar.capture(currentUrl, response);
|
|
445
|
+
const location = response.headers.get("Location");
|
|
446
|
+
if (!location || response.status < 300 || response.status >= 400) {
|
|
447
|
+
return response;
|
|
448
|
+
}
|
|
449
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
450
|
+
init = { redirect: "manual" };
|
|
451
|
+
}
|
|
452
|
+
throw new Error("Too many redirects");
|
|
453
|
+
}
|
|
454
|
+
async function authenticate(email, password) {
|
|
455
|
+
const jar = new CookieJar();
|
|
456
|
+
const signinResp = await fetchFollowingCookies(
|
|
457
|
+
jar,
|
|
458
|
+
"https://my.acculynx.com/signin/"
|
|
459
|
+
);
|
|
460
|
+
const html = await signinResp.text();
|
|
461
|
+
const actionMatch = html.match(/<form[^>]*action="([^"]*)"/);
|
|
462
|
+
if (!actionMatch) throw new Error("Could not find login form action");
|
|
463
|
+
const formAction = actionMatch[1].replace(/&/g, "&");
|
|
464
|
+
const thumbprint = html.match(/name="DeviceThumbprint"[^>]*value="([^"]*)"/)?.[1] ?? "";
|
|
465
|
+
const deviceName = html.match(/name="DeviceName"[^>]*value="([^"]*)"/)?.[1] ?? "Node.js CLI";
|
|
466
|
+
const body = new URLSearchParams({
|
|
467
|
+
Email: email,
|
|
468
|
+
Password: password,
|
|
469
|
+
DeviceThumbprint: thumbprint,
|
|
470
|
+
DeviceName: deviceName
|
|
471
|
+
});
|
|
472
|
+
const loginResp = await fetchFollowingCookies(jar, formAction, {
|
|
473
|
+
method: "POST",
|
|
474
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
475
|
+
body: body.toString()
|
|
476
|
+
});
|
|
477
|
+
const locationResp = await fetchFollowingCookies(
|
|
478
|
+
jar,
|
|
479
|
+
"https://my.acculynx.com/signin/LocationSelector"
|
|
480
|
+
);
|
|
481
|
+
const locationHtml = await locationResp.text();
|
|
482
|
+
return { jar, locationHtml };
|
|
483
|
+
}
|
|
484
|
+
async function listCompanies(email, password) {
|
|
485
|
+
const { locationHtml } = await authenticate(email, password);
|
|
486
|
+
const companyPattern = /SwitchCompany\?CompanyID=([0-9a-f-]+)"[^>]*>([^<]+)/gi;
|
|
487
|
+
const companies = [];
|
|
488
|
+
let match;
|
|
489
|
+
while ((match = companyPattern.exec(locationHtml)) !== null) {
|
|
490
|
+
companies.push({ id: match[1], name: match[2].trim() });
|
|
491
|
+
}
|
|
492
|
+
return companies;
|
|
493
|
+
}
|
|
494
|
+
async function login(email, password, companyId) {
|
|
495
|
+
const { jar } = await authenticate(email, password);
|
|
496
|
+
await fetchFollowingCookies(
|
|
497
|
+
jar,
|
|
498
|
+
`https://my.acculynx.com/signin/SwitchCompany?CompanyID=${companyId}`
|
|
499
|
+
);
|
|
500
|
+
return { jar, companyId, email };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/unofficial/commands/login.ts
|
|
504
|
+
function getCredentials(opts) {
|
|
505
|
+
const email = opts.email ?? process.env.ACCULYNX_EMAIL;
|
|
506
|
+
const password = opts.password ?? process.env.ACCULYNX_PASSWORD;
|
|
507
|
+
const companyId = opts.companyId ?? process.env.ACCULYNX_COMPANY_ID;
|
|
508
|
+
if (!email) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
"Missing email: pass --email or set ACCULYNX_EMAIL"
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (!password) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
"Missing password: pass --password or set ACCULYNX_PASSWORD"
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (!companyId) {
|
|
519
|
+
throw new Error(
|
|
520
|
+
"Missing company ID: pass --company-id or set ACCULYNX_COMPANY_ID"
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
return { email, password, companyId };
|
|
524
|
+
}
|
|
525
|
+
function registerLoginCommands(parentCmd) {
|
|
526
|
+
parentCmd.command("login").description("Authenticate and cache session cookies").option("--email <email>", "AccuLynx email (env: ACCULYNX_EMAIL)").option(
|
|
527
|
+
"--password <password>",
|
|
528
|
+
"AccuLynx password (env: ACCULYNX_PASSWORD)"
|
|
529
|
+
).option(
|
|
530
|
+
"--company-id <id>",
|
|
531
|
+
"Company GUID (env: ACCULYNX_COMPANY_ID)"
|
|
532
|
+
).action(async (opts) => {
|
|
533
|
+
const { email, password, companyId } = getCredentials(opts);
|
|
534
|
+
const session = await login(email, password, companyId);
|
|
535
|
+
saveSession(session);
|
|
536
|
+
console.log(
|
|
537
|
+
JSON.stringify({
|
|
538
|
+
status: "ok",
|
|
539
|
+
email,
|
|
540
|
+
companyId,
|
|
541
|
+
message: "Session cached successfully"
|
|
542
|
+
})
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
parentCmd.command("companies").description(
|
|
546
|
+
"List available companies for your account (discovers company IDs)"
|
|
547
|
+
).option("--email <email>", "AccuLynx email (env: ACCULYNX_EMAIL)").option(
|
|
548
|
+
"--password <password>",
|
|
549
|
+
"AccuLynx password (env: ACCULYNX_PASSWORD)"
|
|
550
|
+
).action(async (opts) => {
|
|
551
|
+
const email = opts.email ?? process.env.ACCULYNX_EMAIL;
|
|
552
|
+
const password = opts.password ?? process.env.ACCULYNX_PASSWORD;
|
|
553
|
+
if (!email) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
"Missing email: pass --email or set ACCULYNX_EMAIL"
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (!password) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
"Missing password: pass --password or set ACCULYNX_PASSWORD"
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
const companies = await listCompanies(email, password);
|
|
564
|
+
console.log(JSON.stringify(companies));
|
|
565
|
+
});
|
|
566
|
+
parentCmd.command("sessions").description("List cached sessions").action(() => {
|
|
567
|
+
const sessions = listSessions();
|
|
568
|
+
console.log(JSON.stringify(sessions));
|
|
569
|
+
});
|
|
570
|
+
parentCmd.command("logout").description("Remove a cached session").option("--email <email>", "AccuLynx email (env: ACCULYNX_EMAIL)").option(
|
|
571
|
+
"--company-id <id>",
|
|
572
|
+
"Company GUID (env: ACCULYNX_COMPANY_ID)"
|
|
573
|
+
).action((opts) => {
|
|
574
|
+
const email = opts.email ?? process.env.ACCULYNX_EMAIL;
|
|
575
|
+
const companyId = opts.companyId ?? process.env.ACCULYNX_COMPANY_ID;
|
|
576
|
+
if (!email || !companyId) {
|
|
577
|
+
throw new Error(
|
|
578
|
+
"Both --email and --company-id are required (or set env vars)"
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const removed = deleteSession(email, companyId);
|
|
582
|
+
console.log(
|
|
583
|
+
JSON.stringify({
|
|
584
|
+
status: removed ? "removed" : "not_found",
|
|
585
|
+
email,
|
|
586
|
+
companyId
|
|
587
|
+
})
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// src/unofficial/commands/documents.ts
|
|
593
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
594
|
+
function registerDocumentsCommands(parentCmd, getClient2) {
|
|
595
|
+
const docs = parentCmd.command("documents").description("Job document operations");
|
|
596
|
+
docs.command("list").argument("<jobId>", "Job ID (GUID)").description("List all document folders and files for a job").action(async (jobId) => {
|
|
597
|
+
const folders = await getClient2().get(
|
|
598
|
+
`/api/v4/job-documents/${jobId}/job-document-folders`
|
|
599
|
+
);
|
|
600
|
+
const result = folders.map((folder) => ({
|
|
601
|
+
folder: folder.Name,
|
|
602
|
+
folderId: folder.Id,
|
|
603
|
+
fileCount: folder.FileCount,
|
|
604
|
+
files: folder.Files.map((f) => ({
|
|
605
|
+
id: f.Id,
|
|
606
|
+
name: f.Name,
|
|
607
|
+
extension: f.Extension,
|
|
608
|
+
size: f.Size,
|
|
609
|
+
mimeType: f.MIMEType,
|
|
610
|
+
href: f.Href,
|
|
611
|
+
createdBy: f.CreatedByDisplayName,
|
|
612
|
+
modifiedDate: f.ModifiedDateUTC
|
|
613
|
+
}))
|
|
614
|
+
}));
|
|
615
|
+
console.log(JSON.stringify(result));
|
|
616
|
+
});
|
|
617
|
+
docs.command("download").argument("<jobId>", "Job ID (GUID)").argument("<fileId>", "File ID (GUID)").option("--output <path>", "Write to file instead of stdout").description("Download a document file").action(async (jobId, fileId, opts) => {
|
|
618
|
+
const folders = await getClient2().get(
|
|
619
|
+
`/api/v4/job-documents/${jobId}/job-document-folders`
|
|
620
|
+
);
|
|
621
|
+
let targetFile;
|
|
622
|
+
for (const folder of folders) {
|
|
623
|
+
targetFile = folder.Files.find((f) => f.Id === fileId);
|
|
624
|
+
if (targetFile) break;
|
|
625
|
+
}
|
|
626
|
+
if (!targetFile) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`File ${fileId} not found in job ${jobId} documents`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
const buffer = await getClient2().getBuffer(targetFile.Href);
|
|
632
|
+
if (opts.output) {
|
|
633
|
+
writeFileSync2(opts.output, buffer);
|
|
634
|
+
console.error(
|
|
635
|
+
JSON.stringify({
|
|
636
|
+
status: "ok",
|
|
637
|
+
file: targetFile.Name,
|
|
638
|
+
size: buffer.length,
|
|
639
|
+
output: opts.output
|
|
640
|
+
})
|
|
641
|
+
);
|
|
642
|
+
} else {
|
|
643
|
+
process.stdout.write(buffer);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/unofficial/commands/messages.ts
|
|
649
|
+
import TurndownService from "turndown";
|
|
650
|
+
function createTurndown() {
|
|
651
|
+
const td = new TurndownService({
|
|
652
|
+
headingStyle: "atx",
|
|
653
|
+
bulletListMarker: "-"
|
|
654
|
+
});
|
|
655
|
+
td.addRule("removeImages", {
|
|
656
|
+
filter: "img",
|
|
657
|
+
replacement: () => ""
|
|
658
|
+
});
|
|
659
|
+
td.addRule("removeStyle", {
|
|
660
|
+
filter: ["style", "script"],
|
|
661
|
+
replacement: () => ""
|
|
662
|
+
});
|
|
663
|
+
return td;
|
|
664
|
+
}
|
|
665
|
+
var looksLikeHtml = /<[a-z][\s\S]*?>/i;
|
|
666
|
+
function cleanMessage(html) {
|
|
667
|
+
if (!looksLikeHtml.test(html)) return html;
|
|
668
|
+
const td = createTurndown();
|
|
669
|
+
return td.turndown(html).replace(/\n{3,}/g, "\n\n").trim();
|
|
670
|
+
}
|
|
671
|
+
function registerMessagesCommands(parentCmd, getClient2) {
|
|
672
|
+
const messages = parentCmd.command("messages").description("Job message/comment operations");
|
|
673
|
+
messages.command("list").argument("<jobId>", "Job ID (GUID)").option(
|
|
674
|
+
"--sort <sort>",
|
|
675
|
+
"Sort field and direction (field|asc or field|desc)",
|
|
676
|
+
"createdDate|desc"
|
|
677
|
+
).option("--count-only", "Return only the total count of messages").option(
|
|
678
|
+
"--type <type>",
|
|
679
|
+
"Filter by message type: Comment, Email, or Signatures"
|
|
680
|
+
).description("List all messages and comments for a job").action(async (jobId, opts) => {
|
|
681
|
+
const params = new URLSearchParams({ sort: opts.sort });
|
|
682
|
+
if (opts.countOnly) {
|
|
683
|
+
params.set("countOnly", "true");
|
|
684
|
+
}
|
|
685
|
+
if (opts.type) {
|
|
686
|
+
params.append("filters", `type=${opts.type}`);
|
|
687
|
+
}
|
|
688
|
+
const data = await getClient2().get(
|
|
689
|
+
`/api/jobs/${jobId}/JobMessageFeed?${params.toString()}`
|
|
690
|
+
);
|
|
691
|
+
if (opts.countOnly) {
|
|
692
|
+
console.log(JSON.stringify({ totalRecords: data.TotalRecords }));
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const result = {
|
|
696
|
+
totalRecords: data.TotalRecords,
|
|
697
|
+
currentPage: data.CurrentPage,
|
|
698
|
+
totalPages: data.TotalPages,
|
|
699
|
+
messages: data.results.map((msg) => ({
|
|
700
|
+
id: msg.id,
|
|
701
|
+
jobId: msg.JobId,
|
|
702
|
+
type: msg.Type,
|
|
703
|
+
messageType: msg.MessageType,
|
|
704
|
+
message: cleanMessage(msg.Message),
|
|
705
|
+
subject: msg.Subject,
|
|
706
|
+
createdBy: msg.CreatedBy,
|
|
707
|
+
createdById: msg.CreatedByID,
|
|
708
|
+
createdDate: msg.CreatedDate,
|
|
709
|
+
latestMessage: msg.LatestMessage,
|
|
710
|
+
parentMessageId: msg.ParentMessageId,
|
|
711
|
+
attachmentOnly: msg.AttachmentOnly,
|
|
712
|
+
replyEmail: msg.ReplyEmail,
|
|
713
|
+
sentDate: msg.SentDate,
|
|
714
|
+
recipients: msg.Recipients.map((r) => ({
|
|
715
|
+
id: r.id,
|
|
716
|
+
companyUserId: r.companyUserId,
|
|
717
|
+
sent: r.Sent,
|
|
718
|
+
sentDate: r.SentDate,
|
|
719
|
+
delivered: r.Delivered,
|
|
720
|
+
deliveredDate: r.DeliveredDate,
|
|
721
|
+
failed: r.Failed,
|
|
722
|
+
failedDate: r.FailedDate,
|
|
723
|
+
failedDescription: r.FailedDescription
|
|
724
|
+
})),
|
|
725
|
+
attachments: msg.Attachments,
|
|
726
|
+
childMessageCount: msg.ChildMessages.length,
|
|
727
|
+
childMessages: msg.ChildMessages.map((child) => ({
|
|
728
|
+
id: child.id,
|
|
729
|
+
type: child.Type,
|
|
730
|
+
message: cleanMessage(child.Message),
|
|
731
|
+
createdBy: child.CreatedBy,
|
|
732
|
+
createdDate: child.CreatedDate
|
|
733
|
+
}))
|
|
734
|
+
}))
|
|
735
|
+
};
|
|
736
|
+
console.log(JSON.stringify(result));
|
|
737
|
+
});
|
|
738
|
+
messages.command("post").argument("<jobId>", "Job ID (GUID)").argument("<message>", "Message text to post").option(
|
|
739
|
+
"--notify <userIds>",
|
|
740
|
+
"Comma-separated user IDs (GUIDs) to notify"
|
|
741
|
+
).description("Post a comment/message to a job, optionally notifying users").action(async (jobId, message, opts) => {
|
|
742
|
+
const emailRecipients = opts.notify ? opts.notify.split(",").map((id) => id.trim()) : [];
|
|
743
|
+
const data = await getClient2().post(
|
|
744
|
+
`/api/jobs/${jobId}/Messages`,
|
|
745
|
+
{
|
|
746
|
+
JobId: jobId,
|
|
747
|
+
Message: message,
|
|
748
|
+
RoleId: "1111111",
|
|
749
|
+
MessageType: "General Comment",
|
|
750
|
+
emailRecipients,
|
|
751
|
+
index: -1
|
|
752
|
+
}
|
|
753
|
+
);
|
|
754
|
+
console.log(
|
|
755
|
+
JSON.stringify({
|
|
756
|
+
id: data.JobMessageId,
|
|
757
|
+
jobId: data.JobId,
|
|
758
|
+
message: data.Message,
|
|
759
|
+
messageType: data.MessageType,
|
|
760
|
+
createdDate: data.CreatedDate,
|
|
761
|
+
notified: emailRecipients.length
|
|
762
|
+
})
|
|
763
|
+
);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// src/unofficial/commands/index.ts
|
|
768
|
+
function getSessionFromEnv() {
|
|
769
|
+
const email = process.env.ACCULYNX_EMAIL;
|
|
770
|
+
const companyId = process.env.ACCULYNX_COMPANY_ID;
|
|
771
|
+
if (!email || !companyId) {
|
|
772
|
+
throw new Error(
|
|
773
|
+
"Set ACCULYNX_EMAIL and ACCULYNX_COMPANY_ID (or run `acculynx unofficial login` first)"
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const session = loadSession(email, companyId);
|
|
777
|
+
if (!session) {
|
|
778
|
+
throw new Error(
|
|
779
|
+
`No cached session for ${email} / ${companyId}. Run \`acculynx unofficial login\` first.`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
return session;
|
|
783
|
+
}
|
|
784
|
+
function registerUnofficialCommands(parentCmd) {
|
|
785
|
+
const unofficial = parentCmd.command("unofficial").description(
|
|
786
|
+
"Unofficial web API commands (cookie-based auth, reverse-engineered)"
|
|
787
|
+
);
|
|
788
|
+
registerLoginCommands(unofficial);
|
|
789
|
+
let _client2 = null;
|
|
790
|
+
function getClient2() {
|
|
791
|
+
if (!_client2) {
|
|
792
|
+
_client2 = createUnofficialClient(getSessionFromEnv());
|
|
793
|
+
}
|
|
794
|
+
return _client2;
|
|
795
|
+
}
|
|
796
|
+
registerDocumentsCommands(unofficial, getClient2);
|
|
797
|
+
registerMessagesCommands(unofficial, getClient2);
|
|
798
|
+
}
|
|
799
|
+
|
|
231
800
|
// src/index.ts
|
|
232
801
|
var program = new Command();
|
|
233
802
|
program.name("acculynx").description("AccuLynx CRM CLI for AI agents").version("0.1.0").exitOverride().configureOutput({
|
|
234
803
|
writeOut: (str) => process.stdout.write(str),
|
|
235
|
-
writeErr: (str) =>
|
|
804
|
+
writeErr: (str) => {
|
|
805
|
+
const trimmed = str.trim();
|
|
806
|
+
if (trimmed === "(outputHelp)") {
|
|
807
|
+
return;
|
|
808
|
+
} else if (trimmed.startsWith("Usage:") || trimmed.startsWith("error:")) {
|
|
809
|
+
process.stderr.write(str);
|
|
810
|
+
} else {
|
|
811
|
+
process.stderr.write(JSON.stringify({ error: trimmed }) + "\n");
|
|
812
|
+
}
|
|
813
|
+
}
|
|
236
814
|
}).option("--api-key <key>", "AccuLynx API key (env: ACCULYNX_API_KEY)");
|
|
237
815
|
var _client = null;
|
|
238
816
|
function getConfigFromProgram() {
|
|
@@ -249,7 +827,11 @@ registerPingCommand(program, getClient);
|
|
|
249
827
|
registerJobsCommands(program, getClient);
|
|
250
828
|
registerContactsCommands(program, getClient);
|
|
251
829
|
registerEstimatesCommands(program, getClient);
|
|
830
|
+
registerUnofficialCommands(program);
|
|
252
831
|
program.parseAsync().catch((err) => {
|
|
832
|
+
if (err.message === "(outputHelp)" || err.message === "(version)") {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
253
835
|
console.error(JSON.stringify({ error: err.message }));
|
|
254
836
|
process.exit(1);
|
|
255
837
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opsrev/acculynx-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "CLI for the AccuLynx roofing CRM API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
11
|
-
"dev": "tsx src/index.ts",
|
|
11
|
+
"dev": "tsx --env-file=.env src/index.ts",
|
|
12
12
|
"test": "vitest run",
|
|
13
13
|
"test:watch": "vitest",
|
|
14
14
|
"prepublishOnly": "npm run build"
|
|
@@ -28,10 +28,12 @@
|
|
|
28
28
|
"dist"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"commander": "^14.0.3"
|
|
31
|
+
"commander": "^14.0.3",
|
|
32
|
+
"turndown": "^7.2.2"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/node": "^25.4.0",
|
|
36
|
+
"@types/turndown": "^5.0.6",
|
|
35
37
|
"tsup": "^8.5.1",
|
|
36
38
|
"tsx": "^4.21.0",
|
|
37
39
|
"typescript": "^5.9.3",
|