strckro 0.0.0-stage → 0.3.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.
@@ -0,0 +1,532 @@
1
+ # Strckro CLI — เอกสารการใช้งาน
2
+
3
+ เวอร์ชัน CLI: `0.3.0`
4
+ คำสั่ง: `skctl`
5
+ อัปเดตเอกสาร: 6 พฤศจิกายน 2026
6
+
7
+ `skctl` เชื่อม Strckro เพื่อจัดการโปรเจค, Git sync, deployment, domain/TLS และ environment variables ผ่าน terminal ใช้ได้ทั้งคน, AI agents และ CI โดยไม่ต้องจัดการ HTTP, session cookie หรือ CSRF เอง
8
+
9
+ ## 1. สิ่งที่ต้องเตรียม
10
+
11
+ - Node.js `20.20.2` ขึ้นไปบนเครื่องที่รัน CLI
12
+ - Strckro backend ที่มี CLI API จากการพัฒนาชุดนี้ และบัญชีที่เข้าใช้งานได้
13
+ - URL ของ Portal เช่น `https://portal.example.com`
14
+ - Host ติดตั้ง Git และ runtime ที่โปรเจคต้องใช้ พร้อมตั้ง Git identity แล้ว
15
+
16
+ CLI เชื่อมผ่าน HTTPS ยกเว้น HTTP บน `localhost`, `127.0.0.1` และ `[::1]` สำหรับพัฒนา URL ต้องเป็น origin เช่น `https://portal.example.com` ไม่มี path ต่อท้าย
17
+
18
+ แพ็กเกจชื่อ `strckro` ติดตั้งผ่าน npm registry, ไฟล์ `.tgz` หรือ source ได้ การอัปเดต CLI อย่างเดียวไม่เพิ่ม API ให้ backend ที่ติดตั้งอยู่ ต้องอัปเดต backend ด้วย
19
+
20
+ ## 2. ติดตั้ง
21
+
22
+ ### ติดตั้งจาก npm registry
23
+
24
+ ```sh
25
+ npm install --global strckro
26
+ skctl --version
27
+ skctl --help
28
+ ```
29
+
30
+ อัปเดตเวอร์ชันล่าสุด:
31
+
32
+ ```sh
33
+ npm install --global strckro@latest
34
+ ```
35
+
36
+ ใช้ชั่วคราวโดยไม่ติดตั้ง global:
37
+
38
+ ```sh
39
+ npx --package strckro skctl --help
40
+ ```
41
+
42
+ ### ติดตั้งจากไฟล์ npm package
43
+
44
+ เครื่องที่มี source สร้างไฟล์สำหรับแจกได้ด้วย:
45
+
46
+ ```sh
47
+ npm run pack:release
48
+ ```
49
+
50
+ ส่ง `strckro-0.3.0.tgz` ไปยังเครื่องอื่นแล้วรัน:
51
+
52
+ ```sh
53
+ npm install --global ./strckro-0.3.0.tgz
54
+ skctl --version
55
+ ```
56
+
57
+ การติดตั้งจาก `.tgz` ไม่ต้องรอ publish หรือ login บัญชี npm
58
+
59
+ ### Windows / PowerShell
60
+
61
+ ```powershell
62
+ cd E:\dashboard-control-cli
63
+ npm link
64
+ skctl --version
65
+ skctl --help
66
+ ```
67
+
68
+ ### Linux / macOS
69
+
70
+ ```sh
71
+ cd /path/to/strckro
72
+ npm link
73
+ skctl --version
74
+ ```
75
+
76
+ หากยังไม่ต้องการติดตั้งคำสั่ง global ให้รันจากโฟลเดอร์ CLI ด้วย:
77
+
78
+ ```sh
79
+ node bin/skctl.mjs --help
80
+ ```
81
+
82
+ ## 3. เริ่มใช้งานอย่างรวดเร็ว
83
+
84
+ ตัวอย่างนี้ใช้โปรเจค `my-app` ที่มีอยู่แล้วบน Portal:
85
+
86
+ ```sh
87
+ skctl login --server https://portal.example.com
88
+ skctl projects list --json
89
+ skctl init --project my-app
90
+ skctl env import --file .env.production
91
+ skctl domains set --domains app.example.com
92
+ skctl deploy --sync --wait --timeout 300 --json
93
+ skctl domains verify --json
94
+ ```
95
+
96
+ เปลี่ยน URL, project slug และ domain เป็นค่าของคุณเอง ก่อน verify domain ให้ตั้ง DNS ชี้มายัง server
97
+
98
+ ## 4. เข้าสู่ระบบ
99
+
100
+ ### 4.1 Login ผ่าน browser
101
+
102
+ ```sh
103
+ skctl login --server https://portal.example.com
104
+ ```
105
+
106
+ CLI แสดงรหัสกับลิงก์และเปิด browser ให้เข้า Portal ด้วยบัญชีเดิม ตรวจว่ารหัสตรงกับ terminal แล้วกด **อนุมัติ CLI** จากนั้น CLI จะรับ token และบันทึก credential บนเครื่อง
107
+
108
+ คำขอ login มีอายุ 10 นาที ไม่ต้องเปิด localhost callback port และไม่ต้องกรอกรหัสผ่านบัญชีใน terminal
109
+
110
+ ### 4.2 Login โดยคัดลอกลิงก์เอง
111
+
112
+ ```sh
113
+ skctl login --server https://portal.example.com --no-browser
114
+ ```
115
+
116
+ CLI แสดงลิงก์โดยไม่เปิด browser ให้คัดลอกไปเปิดและอนุมัติเอง เหมาะกับ SSH หรือเครื่องที่ไม่มี browser โดย CLI จะรอรับผลผ่านการ polling
117
+
118
+ ### 4.3 Login ด้วย token จากหน้าเว็บ
119
+
120
+ เข้า **Settings → Account → CLI และ AI access** บน Portal แล้วสร้าง token เลือกชื่อ, โปรเจค, สิทธิ์และอายุ จากนั้นนำค่าที่แสดงครั้งเดียวมาใช้:
121
+
122
+ ```sh
123
+ skctl login --server https://portal.example.com --token YOUR_CLI_TOKEN
124
+ ```
125
+
126
+ หากรับ token จาก secret manager ให้ส่งผ่าน stdin แทนการใส่ค่าใน command arguments:
127
+
128
+ ```sh
129
+ secret-command | skctl login --server https://portal.example.com --token-stdin
130
+ ```
131
+
132
+ `secret-command` ในตัวอย่างหมายถึงคำสั่งอ่าน secret ของระบบที่คุณใช้อยู่
133
+
134
+ ### 4.4 จำกัดสิทธิ์ของการ login
135
+
136
+ ```sh
137
+ skctl login --server https://portal.example.com --no-browser --project my-app --scope project.view,source.sync,deploy.start,logs.read --name "AI deploy" --days 7
138
+ ```
139
+
140
+ | Option | ความหมาย |
141
+ | --- | --- |
142
+ | `--project SLUG` | จำกัด token ให้ใช้กับโปรเจคที่ระบุ ซึ่งต้องมีอยู่แล้ว |
143
+ | `--scope SCOPE,...` | รายการสิทธิ์ คั่นด้วย comma |
144
+ | `--name LABEL` | ชื่อที่แสดงในรายการ token |
145
+ | `--days N` | อายุ 1–90 วัน ค่าเริ่มต้น 30 วัน |
146
+
147
+ Browser login ค่าเริ่มต้นขอ scopes ที่ CLI รองรับทั้งหมด **ยกเว้น `env.read`** สมาชิกที่มีสิทธิ์น้อยกว่านั้นต้องระบุ `--scope` ให้ตรงกับสิทธิ์ของบัญชี
148
+
149
+ ### 4.5 ตรวจบัญชีและออกจากระบบ
150
+
151
+ ```sh
152
+ skctl whoami --json
153
+ skctl logout
154
+ ```
155
+
156
+ `logout` ลบ credential ที่บันทึกไว้บนเครื่องนี้ การยกเลิก token ทุกสำเนาทำผ่านปุ่มถอนสิทธิ์บน Portal ส่วน `STRCKRO_TOKEN` ที่ตั้งใน environment ต้องล้างจาก environment แยกต่างหาก
157
+
158
+ ## 5. ตั้งค่า server และโปรเจค
159
+
160
+ ```sh
161
+ skctl init --project my-app
162
+ ```
163
+
164
+ คำสั่งตรวจว่าเข้าถึงโปรเจคได้แล้วสร้าง `strckro.json` ใน working directory:
165
+
166
+ ```json
167
+ {
168
+ "server": "https://portal.example.com",
169
+ "project": "my-app"
170
+ }
171
+ ```
172
+
173
+ ไฟล์นี้ไม่มี token และสามารถ commit ให้ทีมใช้ร่วมกันได้ `init` จะไม่เขียนทับไฟล์เดิม CLI อ่านไฟล์จาก working directory ปัจจุบัน
174
+
175
+ ลำดับเลือก server:
176
+
177
+ 1. `--server`
178
+ 2. `STRCKRO_SERVER`
179
+ 3. `server` ใน `strckro.json`
180
+ 4. Server ใน credential ที่บันทึกไว้
181
+
182
+ ลำดับเลือกโปรเจค:
183
+
184
+ 1. `--project`
185
+ 2. Project slug ที่ระบุเป็น positional argument
186
+ 3. `STRCKRO_PROJECT`
187
+ 4. `project` ใน `strckro.json`
188
+
189
+ Credential ที่บันทึกไว้จะใช้กับ server origin เดิมเท่านั้น สามารถใช้ `STRCKRO_TOKEN` สำหรับ CI โดยไม่บันทึก credential ลงเครื่อง
190
+
191
+ **token ไปได้เฉพาะ server ที่คุณเลือกเอง:** `strckro.json` เป็นไฟล์ใน repository ซึ่งอาจมาจากคนอื่น (repo ที่ clone มา, fork ใน CI, repo ที่ AI agent เปิด) CLI จึงไม่ส่ง token ที่คุณให้ (`STRCKRO_TOKEN`, `login --token`, `login --token-stdin`) ไปยัง server ที่ระบุอยู่ใน `strckro.json` เพียงอย่างเดียว Token จะถูกส่งเมื่อ server มาจาก `--server`, `STRCKRO_SERVER` หรือเป็น origin เดียวกับที่ login ไว้บนเครื่องนี้ มิฉะนั้นคำสั่งจะหยุดด้วย exit 2 โดยยังไม่ส่งคำขอใดออกไป CI ที่ใช้ `STRCKRO_TOKEN` ให้ตั้ง `STRCKRO_SERVER` คู่กันเสมอ เช่นเดียวกับตัวอย่างในหัวข้อ CI
192
+
193
+ ## 6. สร้างและซิงก์โปรเจค
194
+
195
+ ### ดูรายการและสถานะ
196
+
197
+ ```sh
198
+ skctl projects list
199
+ skctl project my-app --info
200
+ skctl project --info
201
+ skctl projects list --json
202
+ skctl project my-app --info --json
203
+ ```
204
+
205
+ `projects list` แสดงเฉพาะ slug, ประเภทงาน (`web`/`worker`), runtime, deployment และ process หนึ่งบรรทัดต่อโปรเจค ไม่แสดง repository, release history หรือ logs ในรายการ
206
+
207
+ `project [SLUG] --info` แสดงข้อมูลทีละโปรเจค: ชื่อ, source, branch/directory, sync, active/previous release, งาน deploy และ domains หากไม่ระบุ slug จะใช้ลำดับเลือกโปรเจคในหัวข้อ 5 ส่วน `project [SLUG]` แสดงตารางสรุปเฉพาะโปรเจคนั้น
208
+
209
+ Deployment `active` แสดงสถานะ release ที่บันทึกไว้ ส่วน process เป็นผล runtime probe แยกกัน `unknown` หมายถึงยังยืนยันสถานะ process ไม่ได้ จึงไม่ควรใช้ deployment state เพียงอย่างเดียวสรุปว่าแอป healthy
210
+
211
+ เติม `--json` เพื่อรับข้อมูล API ครบเหมือนเดิม รวม release history และ metadata ที่ token มีสิทธิ์อ่าน โดย stdout เป็น JSON object เดียว ไม่มีตารางหรือข้อความแนะนำ `skctl projects status [SLUG] --json` ยังคงใช้ได้ตามเดิม และให้โครงสร้างเดียวกับ `skctl project [SLUG] --info --json`
212
+
213
+ ### สร้างหรืออัปเดตจากไฟล์
214
+
215
+ สร้าง `project.json` เช่น:
216
+
217
+ ```json
218
+ {
219
+ "name": "My app",
220
+ "slug": "my-app",
221
+ "organizationId": "ORGANIZATION_ID_FROM_WHOAMI",
222
+ "repository": "https://github.com/your-org/your-app.git",
223
+ "branch": "main",
224
+ "directory": "/",
225
+ "runtime": "node",
226
+ "nodeMajor": 24,
227
+ "buildScript": "build",
228
+ "startScript": "start"
229
+ }
230
+ ```
231
+
232
+ ```sh
233
+ skctl whoami --json
234
+ skctl projects sync --file project.json --json
235
+ ```
236
+
237
+ ใช้ organization ID จากผล `whoami` หาก slug ยังไม่มีจะสร้างโปรเจค หากมีแล้วจะอัปเดต configuration และ sync source จึงควรตรวจเนื้อหาไฟล์ก่อนใช้กับโปรเจคเดิม
238
+
239
+ Private repository ใช้ `credentialId` ของ Git credential ที่สร้างบน Portal ไว้แล้ว ส่วน runtime และฟิลด์เพิ่มเติมใช้ contract เดียวกับ `POST /api/projects/sync` ของ backend
240
+
241
+ CLI deploy ใช้ Git source บน Portal ไม่อัปโหลด working directory จากเครื่องที่รัน CLI
242
+
243
+ ## 7. Deployment และ logs
244
+
245
+ | งาน | คำสั่ง |
246
+ | --- | --- |
247
+ | Deploy source ที่ sync แล้ว | `skctl deploy my-app` |
248
+ | Sync Git ก่อน deploy | `skctl deploy my-app --sync` |
249
+ | รอผล deployment | `skctl deploy my-app --sync --wait --timeout 300` |
250
+ | ดู job | `skctl jobs status JOB_ID --json` |
251
+ | ดูประวัติ deployment | `skctl deployments list [SLUG] --json` |
252
+ | อ่าน runtime logs | `skctl logs my-app --json` |
253
+ | Restart | `skctl restart my-app` |
254
+ | Rollback | `skctl rollback my-app --release RELEASE_ID` |
255
+
256
+ `--wait` ใช้กับ `deploy` หากครบเวลาที่กำหนด CLI จะแจ้ง job ID ให้ตรวจต่อ การหยุดรอของ CLI ไม่ได้ยกเลิก deployment บน server
257
+
258
+ หาก sync สร้าง deployment job ผ่าน auto-deploy แล้ว `deploy --sync` จะใช้ job นั้น โปรเจค web ต้องมี ENV อย่างน้อยหนึ่ง key ตาม validation ของ backend และการ deploy ยังคงผ่าน deployment queue/health checks เดิม
259
+
260
+ ## 8. Domain และ TLS
261
+
262
+ ```sh
263
+ skctl domains list my-app --json
264
+ skctl domains set my-app --domains app.example.com,www.example.com
265
+ skctl domains check my-app --hostname app.example.com --json
266
+ skctl domains verify my-app --json
267
+ ```
268
+
269
+ `domains set` **แทนที่รายชื่อโดเมนทั้งหมด** ของโปรเจค ไม่ใช่เพิ่มโดเมนเดียวเข้าไปในรายการเดิม
270
+
271
+ หากต้องการลบทั้งหมด:
272
+
273
+ ```sh
274
+ skctl domains clear my-app --sync
275
+ ```
276
+
277
+ `--sync` ใช้กับ `domains set` หรือ `domains clear` เพื่อเรียกการ sync เดิมของ Portal ส่วน `domains verify` ตรวจ DNS และเรียก workflow Nginx/TLS
278
+
279
+ CLI จัดการ domain configuration บน Portal ไม่สร้าง DNS records ที่ผู้ให้บริการ DNS โหมด demo จะไม่ตั้ง Nginx/TLS จริง และเมื่อ backend ตอบ `ok:false` CLI จะ exit `1` พร้อมผลที่ตรวจต่อได้
280
+
281
+ ## 9. Environment variables
282
+
283
+ ### ดูชื่อ key และ revision
284
+
285
+ ```sh
286
+ skctl env keys my-app --json
287
+ ```
288
+
289
+ คำสั่งนี้ไม่คืนค่า secret ใช้สิทธิ์ `env.read` หรือ `env.write`
290
+
291
+ ### ตั้งค่า key เดียว
292
+
293
+ ```sh
294
+ secret-command | skctl env set API_KEY my-app --stdin
295
+ skctl env set API_KEY my-app --file ./secret.txt
296
+ skctl env set OPTIONAL my-app --empty
297
+ ```
298
+
299
+ เลือกเพียงหนึ่ง option ระหว่าง `--stdin`, `--file` และ `--empty` ค่าต้องอยู่บรรทัดเดียว โดย CLI ลบ newline ท้ายหนึ่งบรรทัดเมื่อรับผ่าน stdin/file
300
+
301
+ ### ลบ key
302
+
303
+ ```sh
304
+ skctl env unset OLD_KEY my-app
305
+ ```
306
+
307
+ ### Import หลาย key
308
+
309
+ ไฟล์ `.env.production` ตัวอย่าง:
310
+
311
+ ```dotenv
312
+ NODE_ENV=production
313
+ APP_NAME=My application
314
+ OPTIONAL=
315
+ ```
316
+
317
+ ```sh
318
+ skctl env import my-app --file .env.production --json
319
+ ```
320
+
321
+ Import merge เฉพาะ keys ที่อยู่ในไฟล์ ไม่ลบ keys อื่นบน server
322
+
323
+ ### Export ค่าไปยังไฟล์
324
+
325
+ ```sh
326
+ skctl env pull my-app --file .env.export
327
+ ```
328
+
329
+ ต้องมี `env.read` แยกต่างหาก สร้างไฟล์ใหม่เท่านั้นและไม่พิมพ์ค่าลง stdout Token จาก default browser login ไม่มี scope นี้ ต้องเลือกเพิ่มเมื่อต้องการอ่านค่า
330
+
331
+ ### รูปแบบและการแก้พร้อมกัน
332
+
333
+ - ชื่อ key ใช้ตัวพิมพ์ใหญ่ ตัวเลข และ underscore โดยเริ่มด้วยตัวอักษรหรือ underscore
334
+ - Dotenv เป็น literal `KEY=value` บรรทัดเดียว ไม่ขยาย `$VARIABLE` หรือ shell expressions และรักษา quote ตามที่เขียน
335
+ - ENV บน backend จำกัด 128 KB
336
+ - การเขียนแก้เฉพาะ keys ที่ส่งและรักษา comment/keys อื่น
337
+ - Backend ตรวจ revision ภายใน transaction หากมีคนแก้แทรกจะตอบ HTTP `412` / exit `5` โดย CLI ไม่ retry ทับอัตโนมัติ
338
+ - ENV ที่บันทึกมีผลกับ deployment ครั้งถัดไป ไม่ deploy ให้อัตโนมัติจากคำสั่ง env
339
+
340
+ ระบุ revision เองได้:
341
+
342
+ ```sh
343
+ skctl env keys my-app --json
344
+ skctl env set API_KEY my-app --file ./secret.txt --revision REVISION_FROM_KEYS
345
+ ```
346
+
347
+ ## 10. ใช้กับ AI agents และ CI
348
+
349
+ AI ใช้ `skctl --help` เพื่อค้นคำสั่ง แล้วเติม `--json` เมื่อเรียก operation เพื่ออ่านผลได้แน่นอน ไม่ต้องต่อ API เอง
350
+
351
+ ตัวอย่างคำสั่งงานที่ให้ AI:
352
+
353
+ > ใช้ skctl ตรวจสถานะ my-app ด้วย --json จากนั้น import ENV จาก .env.production แล้ว deploy ด้วย --sync --wait --timeout 300 และรายงานผล job
354
+
355
+ ### PowerShell
356
+
357
+ ตัวอย่างนี้สมมติว่า secret manager หรือ CI ฉีด `STRCKRO_TOKEN` ให้ process แล้ว:
358
+
359
+ ```powershell
360
+ $env:STRCKRO_SERVER = 'https://portal.example.com'
361
+ $env:STRCKRO_PROJECT = 'my-app'
362
+
363
+ skctl projects status --json
364
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
365
+
366
+ skctl env import --file .env.production --json
367
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
368
+
369
+ skctl deploy --sync --wait --timeout 300 --json
370
+ exit $LASTEXITCODE
371
+ ```
372
+
373
+ ### Bash
374
+
375
+ ```sh
376
+ set -e
377
+ export STRCKRO_SERVER=https://portal.example.com
378
+ export STRCKRO_PROJECT=my-app
379
+ # STRCKRO_TOKEN is injected by your CI secret store.
380
+
381
+ skctl projects status --json
382
+ skctl env import --file .env.production --json
383
+ skctl deploy --sync --wait --timeout 300 --json
384
+ ```
385
+
386
+ Operation ที่ใช้ `--json` คืน JSON object เดียวทาง stdout ลิงก์ login อยู่ stderr ส่วน `--help` และ `--version` เป็นข้อความ ไม่มีค่า ENV ในผลการเขียน
387
+
388
+ ตัวอย่าง error JSON:
389
+
390
+ ```json
391
+ {
392
+ "ok": false,
393
+ "error": {
394
+ "code": "CONFLICT",
395
+ "message": "Environment changed. Read the latest revision and retry.",
396
+ "status": 412
397
+ }
398
+ }
399
+ ```
400
+
401
+ ## 11. สิทธิ์ของ token
402
+
403
+ Token ต้องผ่านทั้ง scope ของ token และสิทธิ์ปัจจุบันของบัญชีในองค์กรนั้น การปิดบัญชี เปลี่ยนรหัสผ่าน ถอน token หรือ token หมดอายุทำให้ใช้งานไม่ได้ การถอนสิทธิ์องค์กรมีผลกับ request ถัดไปและตรวจซ้ำก่อน mutation commit
404
+
405
+ | Scope | ใช้ทำอะไร |
406
+ | --- | --- |
407
+ | `project.view` | ดูรายการและสถานะโปรเจค |
408
+ | `project.create` | สร้างโปรเจคผ่าน sync |
409
+ | `project.configure` | เปลี่ยน configuration ของโปรเจคเดิมผ่าน sync |
410
+ | `source.sync` | Sync source ของโปรเจคเดิม |
411
+ | `deploy.start` | Deploy หรือ restart |
412
+ | `deploy.rollback` | Rollback |
413
+ | `domains.manage` | ตั้งค่า ตรวจและ verify domain |
414
+ | `env.read` | อ่านค่า ENV และ export |
415
+ | `env.write` | แก้ ENV และอ่าน metadata/revision |
416
+ | `logs.read` | อ่าน runtime logs และ deployment jobs |
417
+ | `credentials.use` | ผูกหรือเปลี่ยน Git credential ที่ใช้ sync |
418
+
419
+ Token ไม่สามารถจัดการบัญชี/สิทธิ์ผู้ใช้ ออก token ต่อ จัดการ Git credentials หรือติดตั้ง host tools ส่วน Monitor token เดิมยังคงอ่านอย่างเดียวและใช้แทน CLI token ไม่ได้
420
+
421
+ ## 12. Config และการเก็บ credential
422
+
423
+ | Environment variable | ความหมาย |
424
+ | --- | --- |
425
+ | `STRCKRO_SERVER` | Origin ของ Portal |
426
+ | `STRCKRO_TOKEN` | Token สำหรับ process/CI โดยไม่ต้อง login บันทึกลงเครื่อง ใช้คู่กับ `STRCKRO_SERVER` หรือ `--server` (ไม่ส่งไปยัง server ที่มาจาก `strckro.json` อย่างเดียว) |
427
+ | `STRCKRO_PROJECT` | Default project slug |
428
+ | `STRCKRO_CONFIG_DIR` | เปลี่ยนโฟลเดอร์เก็บ credential |
429
+ | `XDG_CONFIG_HOME` | Base config directory หากไม่ได้กำหนด `STRCKRO_CONFIG_DIR` |
430
+
431
+ ค่าเริ่มต้นอยู่ที่ `$XDG_CONFIG_HOME/strckro/credentials.json` หรือ `~/.config/strckro/credentials.json`
432
+
433
+ Windows เข้ารหัส token ด้วย DPAPI ผูกกับ OS user ปัจจุบัน ส่วน Linux/macOS เก็บไฟล์สิทธิ์ `0600` ในโฟลเดอร์ที่สร้างด้วยสิทธิ์ `0700` Server เก็บเฉพาะ hash ของ token/device secret และ ENV ยังคงเข้ารหัสด้วย vault ของ Portal
434
+
435
+ ## 13. Global options และ exit codes
436
+
437
+ | Option | ความหมาย |
438
+ | --- | --- |
439
+ | `--server URL` | เลือก Portal origin |
440
+ | `--project SLUG` | เลือกโปรเจค |
441
+ | `--json` | คืน operation result เป็น JSON |
442
+ | `--timeout SECONDS` | Request timeout 1–3600 วินาที ค่าเริ่มต้น 30; ใช้เป็นเวลารอ deploy เมื่อมี `--wait` ด้วย |
443
+ | `--help` | แสดงคำสั่ง |
444
+ | `--version` | แสดงเวอร์ชัน CLI |
445
+
446
+ | Exit code | ความหมาย |
447
+ | --- | --- |
448
+ | `0` | สำเร็จ |
449
+ | `1` | API หรือ deployment ไม่สำเร็จ รวม HTTP สำเร็จที่มี `ok:false` |
450
+ | `2` | Input, option หรือ configuration ไม่ถูกต้อง |
451
+ | `3` | ไม่มี credential หรือ token ใช้ไม่ได้ |
452
+ | `4` | ไม่มีสิทธิ์ |
453
+ | `5` | ENV revision เปลี่ยน |
454
+ | `6` | Network error หรือรอ deployment จนครบเวลา |
455
+
456
+ ## 14. แก้ปัญหาเบื้องต้น
457
+
458
+ | อาการ | วิธีตรวจ |
459
+ | --- | --- |
460
+ | หา `skctl` ไม่พบ | รัน `npm link` ในโฟลเดอร์ CLI และตรวจ npm global bin ใน PATH |
461
+ | Login พบ endpoint 404 | ตรวจ URL และอัปเดต backend ให้มี CLI API |
462
+ | Exit 3 | ตรวจ `STRCKRO_TOKEN`, วันหมดอายุ/การถอน token หรือ login ใหม่ |
463
+ | Exit 4 | ตรวจ scope ของ token และสิทธิ์บัญชีในองค์กร/โปรเจค |
464
+ | ENV pull ไม่ได้ แต่เขียนได้ | เพิ่ม `env.read` ใน token และตรวจสิทธิ์บัญชี |
465
+ | Exit 5 | อ่าน keys/revision ใหม่ ตรวจการเปลี่ยนแปลง แล้วค่อยส่งการแก้อีกครั้ง |
466
+ | Deploy แจ้งให้ sync ก่อน | ใช้ `deploy --sync` หรือ `projects sync --file project.json` |
467
+ | Domain verify ยัง pending | ตรวจ DNS, การ deploy และความพร้อมของ Nginx/TLS บน host |
468
+ | Export/init แจ้งไฟล์มีอยู่แล้ว | เลือก path ใหม่ หรือจัดการไฟล์เดิมก่อน คำสั่งไม่เขียนทับให้อัตโนมัติ |
469
+
470
+ ## 15. API สำหรับผู้พัฒนาต่อ
471
+
472
+ CLI เรียก backend เป็นผู้ตรวจสิทธิ์ บันทึก เข้ารหัส และ audit ส่วน client ดูแล input, credential และ output
473
+
474
+ | Method | Endpoint | Authentication |
475
+ | --- | --- | --- |
476
+ | `POST` | `/api/cli/device/start` | Anonymous, จำกัดอัตราการสร้างคำขอ |
477
+ | `POST` | `/api/cli/device/poll` | Device secret, polling interval 2 วินาที |
478
+ | `GET`, `POST` | `/api/cli/devices/:userCode` | Browser session; POST ต้องมี CSRF |
479
+ | `GET`, `POST` | `/api/cli/tokens` | Browser session; POST ต้องมี CSRF |
480
+ | `DELETE` | `/api/cli/tokens/:id` | Browser session และ CSRF |
481
+ | `GET` | `/api/access` | CLI bearer หรือ browser session |
482
+ | `GET` | `/api/projects/:slug/environment/metadata` | Token scope/สิทธิ์ `env.read` หรือ `env.write` |
483
+ | `PATCH` | `/api/projects/:slug/environment` | Token scope/สิทธิ์ `env.write` |
484
+
485
+ Existing project endpoints รับ `Authorization: Bearer <CLI_TOKEN>` ผ่าน allow-list แยกตาม action โดย browser session ยังใช้การตรวจ CSRF เดิม
486
+
487
+ ตัวอย่าง ENV patch body:
488
+
489
+ ```json
490
+ {
491
+ "revision": "REVISION_FROM_METADATA",
492
+ "set": {
493
+ "APP_NAME": "My app",
494
+ "OPTIONAL": ""
495
+ },
496
+ "unset": ["OLD_KEY"]
497
+ }
498
+ ```
499
+
500
+ Backend ตรวจ revision และเขียนใน transaction เดียว การส่ง key ซ้ำระหว่าง `set` กับ `unset` จะถูกปฏิเสธ และ response ไม่คืนค่า secret ที่เขียน
501
+
502
+ ## 16. ทดสอบการพัฒนา
503
+
504
+ ### การย้ายจาก CLI เดิม
505
+
506
+ แพ็กเกจใหม่คือ `strckro` คำสั่ง `skctl` มี alias `strckro` ใช้แทนกันได้ ยังคงรองรับตัวแปร `DASHBOARD_*` แต่ `STRCKRO_*` จะมีลำดับสูงกว่า อ่าน `dpctl.json` เมื่อไม่มี `strckro.json` และอ่าน credential เก่าในโฟลเดอร์ `dpctl` เมื่อไม่มี config override การ login ใหม่บันทึกในโฟลเดอร์ `strckro`; logout ล้าง credential default ทั้งสองที่
507
+
508
+ ### CLI
509
+
510
+ ```powershell
511
+ cd E:\dashboard-control-cli
512
+ npm test
513
+ ```
514
+
515
+ ### ทดสอบ CLI คู่กับ backend
516
+
517
+ ```powershell
518
+ $env:DASHBOARD_BACKEND_PATH = 'E:\dashboard-control'
519
+ npm test
520
+ ```
521
+
522
+ เมื่อไม่กำหนด `DASHBOARD_BACKEND_PATH` จะข้าม paired integration test
523
+
524
+ ### Backend
525
+
526
+ ```powershell
527
+ cd E:\dashboard-control
528
+ node --test test/cli-api.test.mjs
529
+ npm run docs:check
530
+ ```
531
+
532
+ Integration tests ใช้ backend โหมด demo ไม่ได้รับรอง deployment, DNS, Nginx หรือการออก TLS จริงบน production Linux
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dashboard Portal contributors
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/PUBLISHING.md ADDED
@@ -0,0 +1,66 @@
1
+ # เผยแพร่ Strckro CLI บน npm
2
+
3
+ ชื่อแพ็กเกจ: `strckro`
4
+ คำสั่งหลังติดตั้ง: `skctl`
5
+ Registry: `https://registry.npmjs.org/`
6
+ การเข้าถึง: public
7
+
8
+ รุ่น 0.3.0 เปลี่ยน `projects list` เป็นตารางสรุปหนึ่งบรรทัดต่อโปรเจค เพิ่ม `project [SLUG] --info` สำหรับเจาะรายละเอียด และคงข้อมูล `--json` กับคำสั่ง `projects status` เดิมไว้
9
+
10
+ ## ตรวจแพ็กเกจก่อนเผยแพร่
11
+
12
+ ```sh
13
+ npm ci
14
+ npm test
15
+ npm run test:package
16
+ npm publish --dry-run
17
+ ```
18
+
19
+ `test:package` สร้าง tarball จริง ตรวจว่าไฟล์ตรงกับ allow-list แล้วติดตั้งด้วย npm ใน global prefix ชั่วคราว ทดสอบ launcher `skctl --version` และ help จาก working directory อื่น โดยไม่แก้ credential หรือ CLI ที่ติดตั้งแบบ global ของผู้ใช้
20
+
21
+ แพ็กเกจมีเฉพาะ runtime, `README.md`, `DOCUMENTATION.md`, `PUBLISHING.md`, `LICENSE` และ `package.json` ไม่รวม tests, scripts, `.env`, `.npmrc`, token หรือ local project configuration
22
+
23
+ `prepublishOnly` รัน tests และ package smoke อีกครั้งก่อน publish ส่วนเวอร์ชันที่ CLI แสดงอ่านจาก `package.json` โดยตรง
24
+
25
+ ## Login และ publish
26
+
27
+ ```sh
28
+ npm login --auth-type=web --registry=https://registry.npmjs.org/
29
+ npm whoami --registry=https://registry.npmjs.org/
30
+ npm run publish:npm
31
+ ```
32
+
33
+ ทำ browser login และการยืนยัน 2FA ตามที่ npm แสดง ไม่ต้องใส่ password, token หรือรหัส 2FA ลง source หรือส่งให้ผู้อื่น บัญชีที่ publish ต้องมีสิทธิ์ในชื่อแพ็กเกจนี้ หาก npm ไม่ให้ใช้ชื่อดังกล่าว ให้เลือกชื่อใหม่แล้วตรวจ package อีกครั้ง
34
+
35
+ เมื่อตีพิมพ์ `name@version` สำเร็จแล้วไม่สามารถนำเวอร์ชันเดิมมาตีพิมพ์ซ้ำได้ สำหรับ release ถัดไป:
36
+
37
+ ```sh
38
+ npm version patch --no-git-tag-version
39
+ npm run publish:npm
40
+ ```
41
+
42
+ ## ตรวจจาก registry หลัง publish
43
+
44
+ ```sh
45
+ npm view strckro version
46
+ npm install --global strckro
47
+ skctl --version
48
+ ```
49
+
50
+ เครื่องอื่นติดตั้งได้ด้วย `npm install --global strckro` หรือใช้ชั่วคราวด้วย `npx --package strckro skctl --help` หลัง publish สำเร็จ
51
+
52
+ ## แจก tarball โดยไม่ผ่าน registry
53
+
54
+ ```sh
55
+ npm run pack:release
56
+ ```
57
+
58
+ จะได้ `strckro-0.3.0.tgz` ตามเวอร์ชันปัจจุบัน ส่งไฟล์นี้ไปยังเครื่องอื่นแล้วติดตั้งได้ทันที:
59
+
60
+ ```sh
61
+ npm install --global ./strckro-0.3.0.tgz
62
+ skctl --version
63
+ skctl login --server https://portal.example.com --no-browser
64
+ ```
65
+
66
+ อ้างอิง: [npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish), [npm login](https://docs.npmjs.com/cli/v11/commands/npm-login)
package/README.md CHANGED
@@ -1,3 +1,168 @@
1
- # Temporary Holding Version
1
+ # Strckro CLI (`skctl`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CLI สำหรับคน, CI และ AI agents เชื่อม Strckro บน server ของคุณ ใช้ Node.js 20.20.2+ ไม่มี runtime dependencies
4
+
5
+ ## ติดตั้งผ่าน npm
6
+
7
+ เครื่องอื่นติดตั้งผ่าน npm ได้ด้วย:
8
+
9
+ ```sh
10
+ npm install --global strckro
11
+ skctl --version
12
+ skctl login --server https://portal.example.com --no-browser
13
+ ```
14
+
15
+ หรือใช้ชั่วคราวโดยไม่ติดตั้ง global:
16
+
17
+ ```sh
18
+ npx --package strckro skctl --help
19
+ ```
20
+
21
+ หากต้องการแจกไฟล์แพ็กเกจโดยตรง สามารถสร้างและส่งไปติดตั้งบนเครื่องอื่นได้ด้วย:
22
+
23
+ ```sh
24
+ npm run pack:release
25
+ # On the other machine:
26
+ npm install --global ./strckro-0.3.0.tgz
27
+ ```
28
+
29
+ ดู [เอกสารฉบับเต็ม](DOCUMENTATION.md) และ [ขั้นตอน publish](PUBLISHING.md)
30
+
31
+ ## ติดตั้งจาก source
32
+
33
+ ```powershell
34
+ cd E:\dashboard-control-cli
35
+ npm link
36
+ skctl --help
37
+ ```
38
+
39
+ Linux/macOS ใช้ `npm link` ได้เช่นกัน Backend ต้องเป็น revision ที่มี CLI API ชุดนี้ก่อน
40
+
41
+ ## Login
42
+
43
+ ```sh
44
+ skctl login --server https://portal.example.com
45
+ skctl login --server https://portal.example.com --no-browser
46
+ skctl login --server https://portal.example.com --token YOUR_CLI_TOKEN
47
+ skctl whoami
48
+ skctl logout
49
+ ```
50
+
51
+ Browser login เปิด Settings > Account ให้ใช้บัญชีเดิม ตรวจรหัสตรงกับ terminal แล้วกดอนุมัติ ไม่ต้องกรอกรหัสผ่านใน CLI `--no-browser` แสดงลิงก์ให้คัดลอกเอง ใช้จาก SSH/เครื่องอื่นได้โดยไม่เปิด callback port คำขอมีอายุ 10 นาที
52
+
53
+ สร้าง/ถอน token ใน **Settings > Account > CLI และ AI access** แสดงค่าครั้งเดียว จำกัดโปรเจค/scope ได้ อายุ 1–90 วัน (ค่าเริ่มต้น 30) บัญชีถูกปิดหรือเปลี่ยนรหัสผ่านจะใช้ token เดิมไม่ได้ สิทธิ์องค์กรตรวจใหม่ทุก request
54
+
55
+ `--token` รองรับตามข้างบน หากไม่ต้องการ secret ใน shell history/process arguments ให้ใช้ `--token-stdin` หรือ `STRCKRO_TOKEN` ที่ฉีดจาก secret manager:
56
+
57
+ ```sh
58
+ secret-command | skctl login --server https://portal.example.com --token-stdin
59
+ ```
60
+
61
+ Windows เก็บ credential ด้วย DPAPI ผูกกับ OS user; Linux/macOS เก็บไฟล์ `0600` ในโฟลเดอร์ `0700` ใต้ `$XDG_CONFIG_HOME/strckro` หรือ `~/.config/strckro` เปลี่ยนด้วย `STRCKRO_CONFIG_DIR` ได้ HTTPS เท่านั้น ยกเว้น HTTP loopback เพื่อพัฒนา ไม่ตาม redirects และไม่ส่ง credential ที่บันทึกไว้ให้ origin อื่น `logout` ลบเฉพาะเครื่องนี้; ถอนที่หน้าเว็บเพื่อยกเลิกทุกสำเนา
62
+
63
+ ## โปรเจคและ deploy
64
+
65
+ ```sh
66
+ skctl projects list
67
+ skctl project my-app --info
68
+ skctl init --project my-app
69
+ skctl projects status --json
70
+ skctl deployments list --json
71
+ skctl projects sync --file project.json
72
+ skctl env import --file .env.production
73
+ skctl deploy --sync --wait --timeout 300
74
+ skctl logs
75
+ skctl restart
76
+ skctl rollback --release RELEASE_ID
77
+ skctl jobs status JOB_ID
78
+ ```
79
+
80
+ `projects list` แสดงตารางหนึ่งบรรทัดต่อโปรเจค มี slug, ประเภทงาน, runtime และสถานะ deployment/process ดูรายละเอียดด้วย `project [SLUG] --info` หรือใช้โปรเจคจาก `--project`, environment และ `strckro.json` เมื่อไม่ระบุ slug `project [SLUG]` แสดงตารางเฉพาะโปรเจคนั้น `--json` ยังคงคืนข้อมูล API ครบสำหรับ AI/CI ทั้ง list และรายละเอียด และ `projects status [SLUG]` ยังใช้ได้ตามเดิม
81
+
82
+ `init` สร้าง `strckro.json` เก็บแค่ server/project ไม่มี token ไม่เขียนทับไฟล์เดิม อาจ commit ให้ทีมใช้ร่วมกันได้ ลำดับเลือก server: `--server`, `STRCKRO_SERVER`, `strckro.json`, credential ที่บันทึก; project: `--project`, positional slug, `STRCKRO_PROJECT`, `strckro.json`
83
+
84
+ `strckro.json` เป็นไฟล์ใน repository ที่อาจมาจากคนอื่น จึงไม่ถูกใช้เป็นปลายทางของ token ที่คุณให้เอง (`STRCKRO_TOKEN`, `login --token`, `--token-stdin`): token จะส่งเฉพาะเมื่อ server มาจาก `--server`, `STRCKRO_SERVER` หรือ origin ที่ login ไว้บนเครื่องนี้ มิฉะนั้นคำสั่งหยุดด้วย exit 2 โดยไม่ส่งคำขอ ใน CI ให้ตั้ง `STRCKRO_SERVER` คู่กับ `STRCKRO_TOKEN`
85
+
86
+ สร้างโปรเจคด้วย `project.json` ตาม `POST /api/projects/sync` เช่น:
87
+
88
+ ```json
89
+ {
90
+ "name": "My app",
91
+ "slug": "my-app",
92
+ "organizationId": "ID_FROM_WHOAMI",
93
+ "repository": "https://github.com/your-org/your-app.git",
94
+ "branch": "main",
95
+ "directory": "/",
96
+ "runtime": "node",
97
+ "nodeMajor": 24,
98
+ "startScript": "start",
99
+ "buildScript": "build"
100
+ }
101
+ ```
102
+
103
+ Deploy ใช้ Git source บน Portal ไม่อัปโหลด working directory; `--sync` ดึง source ก่อนและใช้ job จาก auto-deploy ถ้ามี Host ต้องติดตั้ง Git/runtime และตั้ง Git identity ตามปกติ Private repository ใช้ `credentialId` ที่สร้างบน Portal ไว้แล้ว runtime ต่าง ๆ ใช้ validation/deployment queue เดิม
104
+
105
+ ## Domain + TLS
106
+
107
+ ```sh
108
+ skctl domains set --domains app.example.com,www.example.com
109
+ skctl domains check --hostname app.example.com
110
+ skctl domains verify
111
+ skctl domains list
112
+ skctl domains clear --sync
113
+ ```
114
+
115
+ `set` แทนที่รายชื่อทั้งหมด `clear` ลบทั้งหมด `verify` ตรวจ DNS และเรียก Nginx/TLS workflow เดิม DNS ต้องชี้ server ก่อน CLI ไม่สร้าง records ที่ผู้ให้บริการ DNS โหมด demo ไม่ตั้ง Nginx/TLS จริง
116
+
117
+ ## Environment
118
+
119
+ ```sh
120
+ skctl env keys
121
+ secret-command | skctl env set API_KEY --stdin
122
+ skctl env set API_KEY --file ./secret.txt
123
+ skctl env set OPTIONAL --empty
124
+ skctl env unset OLD_KEY
125
+ skctl env import --file .env.production
126
+ skctl env pull --file .env.export
127
+ ```
128
+
129
+ `set/import` แก้เฉพาะ keys ที่ส่ง รักษา comment/keys อื่น `unset` ลบ key `--empty` ตั้งค่าว่าง ทุกครั้งเป็น atomic transaction และตรวจ revision ถ้ามีคนแก้แทรกจะ exit `5` ไม่ retry ทับอัตโนมัติ ส่ง `--revision REV` เพื่อกำหนด precondition เองได้ ENV มีผลเมื่อ deploy ครั้งถัดไป
130
+
131
+ Dotenv ใช้ literal `KEY=value` บรรทัดเดียว ไม่ขยาย `$VARIABLE`/shell expressions รักษา quote ตามไฟล์ `set --stdin/--file` ลบ newline ท้ายหนึ่งบรรทัด Backend จำกัด ENV 128 KB `keys` ใช้ `env.read` หรือ `env.write`; เขียนใช้ `env.write`; `pull` ต้องมี `env.read` สร้างไฟล์ใหม่และไม่แสดงค่าลง stdout
132
+
133
+ ## AI / CI
134
+
135
+ ให้ AI ใช้ `skctl --help` ค้นคำสั่ง และเติม `--json` stdout เป็น JSON object เดียว ส่วนลิงก์ login อยู่ stderr ไม่ต้องต่อ HTTP/cookie/CSRF เอง
136
+
137
+ ```sh
138
+ export STRCKRO_SERVER=https://portal.example.com
139
+ export STRCKRO_TOKEN="$(your-secret-manager read portal-token)"
140
+ export STRCKRO_PROJECT=my-app
141
+ skctl projects status --json
142
+ skctl env import --file .env.production --json
143
+ skctl deploy --sync --wait --timeout 300 --json
144
+ ```
145
+
146
+ Browser login ค่าเริ่มต้นเปิด workflow โปรเจคทั้งหมด **ยกเว้น `env.read`** สมาชิกที่สิทธิ์น้อยกว่าให้เลือก scope ตรงกับสิทธิ์ เช่น:
147
+
148
+ ```sh
149
+ skctl login --server https://portal.example.com --no-browser --project my-app --scope project.view,source.sync,deploy.start,logs.read
150
+ ```
151
+
152
+ Scopes: `project.view`, `project.create`, `project.configure`, `source.sync`, `deploy.start`, `deploy.rollback`, `domains.manage`, `env.read`, `env.write`, `logs.read`, `credentials.use` token ไม่สามารถสร้าง token ต่อ/จัดการบัญชี/ติดตั้ง host tools/จัดการ Git credential
153
+
154
+ ## ย้ายจาก dashboard-control-cli
155
+
156
+ แพ็กเกจใหม่ชื่อ `strckro` ใช้ `skctl` หรือ alias `strckro` แทน `dpctl` ตัวแปร `STRCKRO_*` มีลำดับสูงกว่า `DASHBOARD_*` เดิมที่ยังรองรับ อ่าน `dpctl.json` ได้เมื่อไม่มี `strckro.json` และอ่าน credential เก่าจาก config directory `dpctl` ได้เมื่อไม่มี override โฟลเดอร์ Login ครั้งถัดไปจะเก็บในโฟลเดอร์ `strckro` โดยยังใช้ DPAPI บน Windows
157
+
158
+ Exit codes: `0` สำเร็จ, `1` API/deploy, `2` input, `3` authentication, `4` permission, `5` ENV conflict, `6` network/timeout JSON errors: `{ "ok": false, "error": { "code", "message", "status"? } }` HTTP สำเร็จแต่ `ok:false` จะคืนผลนั้นและ exit `1`
159
+
160
+ ## ทดสอบ
161
+
162
+ ```powershell
163
+ npm test
164
+ $env:DASHBOARD_BACKEND_PATH = 'E:\dashboard-control'
165
+ npm test
166
+ ```
167
+
168
+ กำหนด `DASHBOARD_BACKEND_PATH` เพื่อเพิ่ม integration tests กับ backend จริงในโหมด demo ซึ่งไม่รับรอง production Linux/Nginx/TLS
package/bin/skctl.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.mjs';
3
+ process.exitCode = await run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "strckro",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Strckro deployment control plane CLI for people, CI, and AI agents",
5
+ "keywords": [
6
+ "strckro",
7
+ "cli",
8
+ "deployment",
9
+ "environment",
10
+ "domains",
11
+ "ai"
12
+ ],
13
+ "type": "module",
14
+ "license": "MIT",
15
+ "engines": {
16
+ "node": ">=20.20.2"
17
+ },
18
+ "bin": {
19
+ "skctl": "bin/skctl.mjs",
20
+ "strckro": "bin/skctl.mjs"
21
+ },
22
+ "files": [
23
+ "bin/skctl.mjs",
24
+ "src/cli.mjs",
25
+ "src/client.mjs",
26
+ "src/config.mjs",
27
+ "README.md",
28
+ "DOCUMENTATION.md",
29
+ "PUBLISHING.md",
30
+ "LICENSE"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org/"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test",
38
+ "test:package": "node scripts/smoke-package.mjs",
39
+ "prepublishOnly": "node scripts/verify-release.mjs",
40
+ "pack:release": "npm pack",
41
+ "publish:npm": "npm publish --access public",
42
+ "start": "node bin/skctl.mjs"
43
+ }
44
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,322 @@
1
+ import { parseArgs, stripVTControlCharacters } from 'node:util';
2
+ import { readFileSync } from 'node:fs';
3
+ import { readFile, writeFile } from 'node:fs/promises';
4
+ import { hostname } from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { createClient, CliError } from './client.mjs';
9
+ import { normalizeServer, readConfig, saveConfig, clearConfig, clientEnvironment } from './config.mjs';
10
+
11
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
12
+
13
+ export const HELP = `skctl — Strckro CLI ${version}
14
+
15
+ Authentication:
16
+ login --server URL [--no-browser] [--scope SCOPE,...] [--project SLUG]
17
+ login --server URL --token TOKEN Use a token issued in Settings > Account
18
+ login --server URL --token-stdin Read a token from stdin
19
+ whoami Check the current account
20
+ logout Remove this machine's saved credential
21
+
22
+ Projects and deployment:
23
+ init --project SLUG Save server/project to ./strckro.json
24
+ projects list Compact project overview
25
+ project [SLUG] [--info] Overview or details for one project
26
+ projects status [SLUG]
27
+ projects sync --file project.json Create/update and sync a Git project
28
+ deployments list [SLUG] List deployment history and active jobs
29
+ deploy [SLUG] [--sync] [--wait] Deploy the synced Git revision
30
+ restart [SLUG]
31
+ rollback [SLUG] --release ID
32
+ jobs status ID
33
+ logs [SLUG]
34
+
35
+ Domains:
36
+ domains list [SLUG]
37
+ domains set [SLUG] --domains a.com,b.com [--sync]
38
+ domains clear [SLUG] Remove all configured domains
39
+ domains check [SLUG] --hostname a.com
40
+ domains verify [SLUG] Verify DNS and apply Nginx/TLS
41
+
42
+ Environment:
43
+ env keys [SLUG] Names and revision only
44
+ env set KEY [SLUG] --stdin|--file PATH|--empty
45
+ env unset KEY [SLUG]
46
+ env import [SLUG] --file .env Merge keys atomically
47
+ env pull [SLUG] --file PATH Export values; requires env.read
48
+
49
+ Global options:
50
+ --server URL --project SLUG --json --timeout SECONDS --help
51
+ --scope SCOPE,... --name LABEL --days 1..90 (login)
52
+ --revision REV (env writes; otherwise fetch the current revision)
53
+
54
+ Environment: STRCKRO_SERVER, STRCKRO_TOKEN, STRCKRO_PROJECT,
55
+ STRCKRO_CONFIG_DIR, XDG_CONFIG_HOME
56
+ A token (STRCKRO_TOKEN or login --token) is sent only to --server, STRCKRO_SERVER
57
+ or the server of your saved login, never to one named only by ./strckro.json.
58
+ Legacy DASHBOARD_* variables and dpctl.json remain supported.
59
+ Exit codes: 0 success; 1 API/deployment; 2 input; 3 authentication;
60
+ 4 permission denied; 5 stale environment; 6 network/timeout.
61
+ Deploy uses the repository configured on Portal; it does not upload local files.
62
+ Use --json for AI/CI: stdout contains one JSON object; progress goes to stderr.
63
+ `;
64
+
65
+ const stringOptions = ['server', 'token', 'project', 'scope', 'name', 'days', 'file', 'domains', 'hostname', 'release', 'revision', 'timeout'];
66
+ const booleanOptions = ['json', 'help', 'version', 'no-browser', 'token-stdin', 'stdin', 'empty', 'wait', 'sync', 'info'];
67
+ const options = Object.fromEntries([...stringOptions.map(key => [key, { type: 'string' }]), ...booleanOptions.map(key => [key, { type: 'boolean' }])]);
68
+ const slug = value => { if (!/^[a-z][a-z0-9-]{0,62}$/.test(value || '')) throw new CliError('Specify a valid project slug with --project or skctl init.'); return value; };
69
+ const keyName = value => { if (!/^[A-Z_][A-Z0-9_]*$/.test(value || '')) throw new CliError('Specify an uppercase environment key.'); return value; };
70
+ const required = (value, flag) => { if (!value) throw new CliError(`Provide ${flag}.`); return value; };
71
+
72
+ // Keep each table entry on one line, even when a server field contains control characters.
73
+ const display = value => stripVTControlCharacters(String(value ?? '')).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim() || '-';
74
+
75
+ function projectList(projects) {
76
+ if (!projects.length) return 'No projects found.\n';
77
+ const rows = [
78
+ ['SLUG', 'TYPE', 'RUNTIME', 'DEPLOYMENT', 'PROCESS'],
79
+ ...projects.map(item => [item.slug, item.serviceType || 'web', item.runtime, item.deployment?.state, item.runtimeStatus?.state || 'unknown'].map(display))
80
+ ];
81
+ const widths = rows[0].map((_, index) => Math.max(...rows.map(row => row[index].length)));
82
+ return rows.map(row => row.map((value, index) => value.padEnd(widths[index])).join(' ').trimEnd()).join('\n')
83
+ + `\n\n${projects.length} project${projects.length === 1 ? '' : 's'}. Details: skctl project <slug> --info\n`;
84
+ }
85
+
86
+ function projectInfo(item) {
87
+ const rows = [
88
+ ['Name', item.name], ['Slug', item.slug], ['Type', item.serviceType || 'web'], ['Runtime', item.runtime],
89
+ ['Deployment', item.deployment?.state], ['Process', item.runtimeStatus?.state || 'unknown'],
90
+ ['Process checked', item.runtimeStatus?.checkedAt],
91
+ ['Active release', item.deployment?.activeReleaseId], ['Previous release', item.deployment?.previousReleaseId],
92
+ ['Deployment updated', item.deployment?.updatedAt],
93
+ ['Deploy job', item.deploymentProgress?.id], ['Job status', item.deploymentProgress?.status], ['Job phase', item.deploymentProgress?.phase],
94
+ ['Repository', item.repository], ['Branch', item.branch], ['Directory', item.directory],
95
+ ['Source sync', item.sync?.status], ['Source synced', item.sync?.at],
96
+ ['Domains', item.domains?.hosts?.join(', ') || '(none)']
97
+ ];
98
+ const width = Math.max(...rows.map(([label]) => label.length));
99
+ return rows.map(([label, value]) => `${label.padEnd(width)} ${display(value)}`).join('\n')
100
+ + '\n\nUse --json for the complete project data.\n';
101
+ }
102
+
103
+ async function stdinText(input) {
104
+ if (input.isTTY) throw new CliError('Pipe the value into stdin.');
105
+ const chunks = []; let size = 0;
106
+ for await (const chunk of input) { size += Buffer.byteLength(chunk); if (size > 256 * 1024) throw new CliError('Input exceeds 256 KB.'); chunks.push(Buffer.from(chunk)); }
107
+ return Buffer.concat(chunks).toString('utf8');
108
+ }
109
+ function openBrowser(url) {
110
+ return new Promise(resolve => {
111
+ const command = process.platform === 'win32' ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
112
+ const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore', windowsHide: true });
113
+ child.once('error', () => resolve(false));
114
+ child.once('spawn', () => { child.unref(); resolve(true); });
115
+ });
116
+ }
117
+ async function localProject(cwd) {
118
+ for (const file of ['strckro.json', 'dpctl.json']) {
119
+ try { return JSON.parse(await readFile(resolve(cwd, file), 'utf8')); }
120
+ catch (error) { if (error.code === 'ENOENT') continue; throw new CliError(`Invalid ${file}.`); }
121
+ }
122
+ return {};
123
+ }
124
+ // Literal dotenv document: preserve quotes, never expand shell expressions.
125
+ export function parseEnv(content) {
126
+ if (Buffer.byteLength(content) > 128 * 1024) throw new CliError('Environment file exceeds 128 KB.');
127
+ const result = Object.create(null);
128
+ for (const line of content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n')) {
129
+ if (!line.trim() || line.trimStart().startsWith('#')) continue;
130
+ const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=(.*)$/);
131
+ if (!match || /[\r\0]/.test(line)) throw new CliError('Invalid environment file; use single-line KEY=value entries.');
132
+ if (Object.hasOwn(result, match[1])) throw new CliError(`Duplicate environment key: ${match[1]}.`);
133
+ result[match[1]] = match[2];
134
+ }
135
+ return result;
136
+ }
137
+
138
+ export async function run(argv, { env = process.env, cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr, stdin = process.stdin, browser = openBrowser } = {}) {
139
+ env = clientEnvironment(env);
140
+ let json = argv.includes('--json');
141
+ const print = result => stdout.write(JSON.stringify(result, null, json ? 0 : 2) + '\n');
142
+ try {
143
+ let parsed;
144
+ try { parsed = parseArgs({ args: argv, options, allowPositionals: true, strict: true }); }
145
+ catch { throw new CliError('Unknown or incomplete option. Run skctl --help.'); }
146
+ const { values: flags, positionals: args } = parsed;
147
+ json = Boolean(flags.json);
148
+ if (flags.help || !args.length && !flags.version) { stdout.write(HELP); return 0; }
149
+ if (flags.version) { stdout.write(version + '\n'); return 0; }
150
+ const [command, action, ...rest] = args;
151
+ // Logout does not need a working server or credential decryption.
152
+ if (command === 'logout') { if (args.length !== 1) throw new CliError('Usage: skctl logout'); await clearConfig(env); print({ ok: true, message: 'Saved credential removed. Revoke it in Portal Settings > Account to invalidate it.' }); return 0; }
153
+ const supported = ['login', 'whoami', 'init', 'project', 'projects', 'deployments', 'deploy', 'restart', 'rollback', 'jobs', 'logs', 'domains', 'env'];
154
+ if (!supported.includes(command)) throw new CliError('Unknown command. Run skctl --help.');
155
+ const commandOptions = command === 'login' ? ['token', 'token-stdin', 'no-browser', 'scope', 'name', 'days']
156
+ : command === 'project' ? ['info']
157
+ : command === 'projects' && action === 'sync' ? ['file']
158
+ : command === 'deploy' ? ['sync', 'wait']
159
+ : command === 'rollback' ? ['release']
160
+ : command === 'domains' ? action === 'set' ? ['domains', 'sync'] : action === 'clear' ? ['sync'] : action === 'check' ? ['hostname'] : []
161
+ : command === 'env' ? action === 'set' ? ['stdin', 'file', 'empty', 'revision'] : action === 'unset' ? ['revision'] : action === 'import' ? ['file', 'revision'] : action === 'pull' ? ['file'] : [] : [];
162
+ for (const flag of Object.keys(flags)) if (!['server', 'project', 'json', 'timeout', 'help', 'version', ...commandOptions].includes(flag)) throw new CliError(`--${flag} does not apply to this command.`);
163
+ const local = await localProject(cwd);
164
+ const explicitServer = flags.server || env.DASHBOARD_SERVER;
165
+ const requestedServer = explicitServer || local.server;
166
+ const needsCredential = command !== 'login' && !env.DASHBOARD_TOKEN;
167
+ // A token the person supplies (STRCKRO_TOKEN, or login --token/--token-stdin) is a bearer
168
+ // secret, and a login, even by browser approval, ends by replacing the credential saved on this
169
+ // machine. strckro.json is repository content, possibly from a stranger, so it must not pick
170
+ // the server for either: only --server, STRCKRO_SERVER or the origin already logged in to on
171
+ // this machine may be trusted with them.
172
+ const needsTrustedServer = command === 'login' || Boolean(env.DASHBOARD_TOKEN);
173
+ let saved = !requestedServer || needsCredential || (needsTrustedServer && !explicitServer) ? await readConfig(env, { secret: false }) : {};
174
+ const server = normalizeServer(requestedServer || saved.server);
175
+ if (needsTrustedServer && !explicitServer && saved.server !== server) throw new CliError(`Refusing to use ${server}: that server comes only from strckro.json, which is repository content, so no token or login is sent there. Pass --server or set STRCKRO_SERVER to the Portal you trust.`);
176
+ if (needsCredential && saved.server === server) saved = await readConfig(env);
177
+ const timeout = flags.timeout === undefined ? 30 : Number(flags.timeout);
178
+ if (!Number.isFinite(timeout) || timeout < 1 || timeout > 3600) throw new CliError('Timeout must be 1–3600 seconds.');
179
+ let token = env.DASHBOARD_TOKEN || (saved.server === server ? saved.token : undefined);
180
+ let api = createClient({ server, token, timeout: timeout * 1000 });
181
+ const project = positional => slug(flags.project || positional || env.DASHBOARD_PROJECT || local.project);
182
+ const route = value => `/api/projects/${encodeURIComponent(value)}`;
183
+ if (command === 'login') {
184
+ if (args.length > 1) throw new CliError('Usage: skctl login --server URL');
185
+ if (flags.token && flags['token-stdin']) throw new CliError('Choose --token or --token-stdin.');
186
+ if (flags['token-stdin'] || flags.token || env.DASHBOARD_TOKEN) {
187
+ token = flags['token-stdin'] ? (await stdinText(stdin)).trim() : flags.token || env.DASHBOARD_TOKEN;
188
+ } else {
189
+ token = undefined;
190
+ const days = flags.days === undefined ? 30 : Number(flags.days);
191
+ if (!Number.isInteger(days) || days < 1 || days > 90) throw new CliError('Days must be 1–90.');
192
+ const device = await api('/api/cli/device/start', { method: 'POST', anonymous: true, body: { name: flags.name || `skctl @ ${hostname()}`, expiresInDays: days, ...(flags.scope ? { scopes: flags.scope.split(',') } : {}), ...(flags.project ? { projectSlug: slug(flags.project) } : {}) } });
193
+ const link = new URL(device.verificationPath, server);
194
+ if (link.origin !== server || link.pathname !== '/settings/account') throw new CliError('Invalid login URL returned by Portal.', 'INVALID_RESPONSE');
195
+ stderr.write(`Open this link and verify code ${device.userCode}:\n${link.href}\n`);
196
+ if (!flags['no-browser']) await browser(link.href);
197
+ const deadline = Date.now() + device.expiresIn * 1000;
198
+ let interval = Math.max(2, device.interval || 2);
199
+ while (Date.now() < deadline) {
200
+ await delay(interval * 1000);
201
+ let polled;
202
+ try { polled = await api('/api/cli/device/poll', { method: 'POST', anonymous: true, body: { deviceCode: device.deviceCode } }); }
203
+ catch (error) { if (error.status === 429) { interval = Math.min(10, interval + 2); continue; } throw error; }
204
+ if (polled.status === 'complete') { token = polled.token; break; }
205
+ if (polled.status !== 'pending') throw new CliError('Unexpected login response.', 'INVALID_RESPONSE');
206
+ }
207
+ if (!token) throw new CliError('Login expired. Run login again.', 'AUTH_REQUIRED', 401);
208
+ }
209
+ if (!/^dp_[A-Za-z0-9_-]{43}$/.test(token || '')) throw new CliError('Use a CLI token issued in Portal Settings > Account.');
210
+ api = createClient({ server, token, timeout: timeout * 1000 });
211
+ const identity = await api('/api/access');
212
+ await saveConfig({ server, token }, env);
213
+ print({ ok: true, server, user: identity.user });
214
+ return 0;
215
+ }
216
+ let result;
217
+ if (command === 'whoami') {
218
+ if (args.length !== 1) throw new CliError('Usage: skctl whoami');
219
+ result = { server, ...await api('/api/access') };
220
+ } else if (command === 'deployments') {
221
+ if (action !== 'list' || rest.length > 1) throw new CliError('Usage: skctl deployments list [SLUG]');
222
+ const target = flags.project || rest[0] || env.DASHBOARD_PROJECT || local.project;
223
+ result = await api('/api/deployments' + (target ? `?project=${encodeURIComponent(slug(target))}` : ''));
224
+ } else if (command === 'init') {
225
+ if (args.length !== 1) throw new CliError('Usage: skctl init --project SLUG');
226
+ const target = project();
227
+ const { projects } = await api('/api/projects');
228
+ if (!projects.some(item => item.slug === target)) throw new CliError('Project not found.', 'API_ERROR', 404);
229
+ await writeFile(resolve(cwd, 'strckro.json'), JSON.stringify({ server, project: target }, null, 2) + '\n', { flag: 'wx' });
230
+ result = { ok: true, server, project: target, file: resolve(cwd, 'strckro.json') };
231
+ } else if (command === 'project') {
232
+ if (args.length > 2) throw new CliError('Usage: skctl project [SLUG] [--info]');
233
+ const target = project(action);
234
+ const { projects } = await api('/api/projects');
235
+ result = { project: projects.find(item => item.slug === target) };
236
+ if (!result.project) throw new CliError('Project not found.', 'API_ERROR', 404);
237
+ } else if (command === 'projects') {
238
+ if (!['list', 'status', 'sync'].includes(action) || rest.length > (action === 'status' ? 1 : 0)) throw new CliError('Usage: skctl projects list|status [SLUG]|sync --file project.json');
239
+ if (action === 'sync') {
240
+ let body;
241
+ try { body = JSON.parse(await readFile(resolve(cwd, required(flags.file, '--file project.json')), 'utf8')); } catch (error) { if (error instanceof CliError) throw error; throw new CliError('Could not read project JSON file.'); }
242
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new CliError('Project file must be a JSON object.');
243
+ slug(body.slug);
244
+ result = await api('/api/projects/sync', { method: 'POST', body });
245
+ } else {
246
+ result = await api('/api/projects');
247
+ if (action === 'status') { const target = project(rest[0]); result = { project: result.projects.find(item => item.slug === target) }; if (!result.project) throw new CliError('Project not found.', 'API_ERROR', 404); }
248
+ }
249
+ } else if (['deploy', 'restart', 'rollback', 'logs'].includes(command)) {
250
+ if (args.length > 2) throw new CliError(`Usage: skctl ${command} [SLUG]`);
251
+ const target = project(action);
252
+ if (flags.sync) {
253
+ if (command !== 'deploy') throw new CliError('--sync applies to deploy.');
254
+ const { projects } = await api('/api/projects');
255
+ const existing = projects.find(item => item.slug === target);
256
+ if (!existing) throw new CliError('Project not found.', 'API_ERROR', 404);
257
+ result = await api('/api/projects/sync', { method: 'POST', body: { ...existing, domains: existing.domains?.hosts } });
258
+ }
259
+ if (!(command === 'deploy' && result?.job)) result = await api(`${route(target)}/${command}`, command === 'logs' ? {} : { method: 'POST', body: command === 'rollback' ? { releaseId: required(flags.release, '--release ID') } : {} });
260
+ if (flags.wait && result.job) {
261
+ const deadline = Date.now() + timeout * 1000;
262
+ while (['queued', 'running'].includes(result.job.status)) {
263
+ if (Date.now() >= deadline) throw new CliError(`Deployment is still running. Check skctl jobs status ${result.job.id}.`, 'TIMEOUT');
264
+ await delay(1000);
265
+ const polled = await api(`/api/jobs/${encodeURIComponent(result.job.id)}`);
266
+ result = { ...result, job: polled.job };
267
+ }
268
+ if (result.job.status !== 'succeeded') throw new CliError(`Deployment ${result.job.status}. Check skctl jobs status ${result.job.id}.`, 'DEPLOYMENT_FAILED');
269
+ }
270
+ } else if (command === 'jobs') {
271
+ if (action !== 'status' || rest.length !== 1 || !/^[a-f0-9-]{36}$/i.test(rest[0])) throw new CliError('Usage: skctl jobs status ID');
272
+ result = await api(`/api/jobs/${encodeURIComponent(rest[0])}`);
273
+ } else if (command === 'domains') {
274
+ if (!['list', 'set', 'clear', 'check', 'verify'].includes(action) || rest.length > 1) throw new CliError('Usage: skctl domains list|set|clear|check|verify [SLUG]');
275
+ const target = project(rest[0]);
276
+ if (action === 'list') {
277
+ const { projects } = await api('/api/projects');
278
+ const item = projects.find(item => item.slug === target);
279
+ if (!item) throw new CliError('Project not found.', 'API_ERROR', 404);
280
+ result = { project: target, domains: item.domains ?? { hosts: [] } };
281
+ } else {
282
+ const endpoint = ['set', 'clear'].includes(action) ? 'domains' : `domains/${action}`;
283
+ const body = action === 'set' ? { domains: required(flags.domains, '--domains a.com,b.com').split(','), sync: Boolean(flags.sync) } : action === 'clear' ? { domains: [], sync: Boolean(flags.sync) } : action === 'check' ? { hostname: required(flags.hostname, '--hostname a.com') } : {};
284
+ result = await api(`${route(target)}/${endpoint}`, { method: 'POST', body });
285
+ }
286
+ } else if (command === 'env') {
287
+ if (!['keys', 'set', 'unset', 'import', 'pull'].includes(action) || rest.length > (['set', 'unset'].includes(action) ? 2 : 1)) throw new CliError('Usage: skctl env keys|set|unset|import|pull; see --help.');
288
+ const key = ['set', 'unset'].includes(action) ? keyName(rest[0]) : null;
289
+ const target = project(rest[key ? 1 : 0]);
290
+ const endpoint = `${route(target)}/environment`;
291
+ if (action === 'keys') result = await api(`${endpoint}/metadata`);
292
+ else if (action === 'pull') {
293
+ const file = resolve(cwd, required(flags.file, '--file PATH'));
294
+ const response = await api(endpoint);
295
+ await writeFile(file, response.environment.content, { flag: 'wx', mode: 0o600 });
296
+ result = { ok: true, file, keys: response.environment.variables.map(item => item.key) };
297
+ } else {
298
+ let set = {}, unset = [];
299
+ if (action === 'set') {
300
+ if ([flags.stdin, Boolean(flags.file), flags.empty].filter(Boolean).length !== 1) throw new CliError('Choose exactly one of --stdin, --file PATH, or --empty.');
301
+ const value = flags.empty ? '' : flags.stdin ? await stdinText(stdin) : await readFile(resolve(cwd, flags.file), 'utf8');
302
+ const cleaned = value.replace(/\r?\n$/, '');
303
+ if (/[\r\n\0]/.test(cleaned)) throw new CliError('Environment values must be single-line strings.');
304
+ set = { [key]: cleaned };
305
+ } else if (action === 'unset') unset = [key];
306
+ else { set = parseEnv(await readFile(resolve(cwd, required(flags.file, '--file .env')), 'utf8')); if (!Object.keys(set).length) throw new CliError('Environment file has no keys.'); }
307
+ const revision = flags.revision || (await api(`${endpoint}/metadata`)).environment.revision;
308
+ result = await api(endpoint, { method: 'PATCH', body: { revision, set, unset } });
309
+ }
310
+ }
311
+ if (!json && result?.ok !== false && command === 'projects' && action === 'list') stdout.write(projectList(result.projects));
312
+ else if (!json && result?.ok !== false && command === 'project') stdout.write(flags.info ? projectInfo(result.project) : projectList([result.project]));
313
+ else print({ ok: true, ...result });
314
+ return result?.ok === false ? 1 : 0;
315
+ } catch (error) {
316
+ const message = error instanceof CliError ? error.message : error.code === 'EEXIST' ? 'Output file already exists; choose a new path.' : error.code?.startsWith('E') ? 'Could not access the requested file or configuration.' : error.message || 'Command failed.';
317
+ const code = error instanceof CliError ? error.code : 'INVALID_INPUT';
318
+ if (json) print({ ok: false, error: { code, message, ...(error.status ? { status: error.status } : {}) } });
319
+ else stderr.write(`Error: ${message}\n`);
320
+ return error.status === 401 ? 3 : error.status === 403 ? 4 : error.status === 412 ? 5 : ['NETWORK_ERROR', 'TIMEOUT'].includes(code) ? 6 : code === 'INVALID_INPUT' ? 2 : 1;
321
+ }
322
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,16 @@
1
+ export class CliError extends Error {
2
+ constructor(message, code = 'INVALID_INPUT', status) { super(message); this.code = code; this.status = status; }
3
+ }
4
+ export function createClient({ server, token, timeout = 30_000, fetchImpl = fetch }) {
5
+ return async (path, { method = 'GET', body, anonymous = false } = {}) => {
6
+ if (!anonymous && !token) throw new CliError('Run skctl login, or set STRCKRO_TOKEN.', 'AUTH_REQUIRED', 401);
7
+ let response;
8
+ try {
9
+ response = await fetchImpl(server + path, { method, redirect: 'error', signal: AbortSignal.timeout(timeout), headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(!anonymous && token ? { authorization: `Bearer ${token}` } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
10
+ } catch { throw new CliError('Could not reach Portal. Check the server URL, TLS certificate, and network.', 'NETWORK_ERROR'); }
11
+ let result;
12
+ try { result = await response.json(); } catch { throw new CliError('Portal returned an invalid API response. Check server compatibility.', 'INVALID_RESPONSE', response.status); }
13
+ if (!response.ok) throw new CliError(result.error || `Portal returned HTTP ${response.status}.`, response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'FORBIDDEN' : response.status === 412 ? 'CONFLICT' : 'API_ERROR', response.status);
14
+ return result;
15
+ };
16
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,66 @@
1
+ import { mkdir, readFile, rename, writeFile, rm } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ export function normalizeServer(value) {
8
+ let url;
9
+ try { url = new URL(value); } catch { throw new Error('Set --server https://your-portal or STRCKRO_SERVER.'); }
10
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('Server must be an origin, for example https://portal.example.com.');
11
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('HTTPS is required (HTTP is allowed only on loopback for local development).');
12
+ return url.origin;
13
+ }
14
+ export function configDirectory(env = process.env) {
15
+ return env.STRCKRO_CONFIG_DIR || env.DASHBOARD_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'strckro');
16
+ }
17
+ export function clientEnvironment(env) {
18
+ return { ...env, ...Object.fromEntries(['SERVER', 'TOKEN', 'PROJECT', 'CONFIG_DIR'].map(key => [`DASHBOARD_${key}`, env[`STRCKRO_${key}`] ?? env[`DASHBOARD_${key}`]])) };
19
+ }
20
+ function legacyCredentialPath(env) {
21
+ if (env.STRCKRO_CONFIG_DIR || env.DASHBOARD_CONFIG_DIR) return null;
22
+ return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'dpctl', 'credentials.json');
23
+ }
24
+ async function protect(value, decrypt = false) {
25
+ // DPAPI binds Windows credentials to the current OS user. The secret travels
26
+ // over stdin, never in command arguments, environment, or diagnostic output.
27
+ const expression = decrypt
28
+ ? '[Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($v), $null, [Security.Cryptography.DataProtectionScope]::CurrentUser))'
29
+ : '[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($v), $null, [Security.Cryptography.DataProtectionScope]::CurrentUser))';
30
+ return new Promise((resolve, reject) => {
31
+ const child = spawn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `Add-Type -AssemblyName System.Security; $v = [Console]::In.ReadToEnd(); [Console]::Out.Write(${expression})`], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
32
+ let output = '';
33
+ child.stdout.on('data', chunk => { output += chunk; });
34
+ child.stderr.resume();
35
+ child.on('error', () => reject(new Error('Windows credential protection is unavailable. Use STRCKRO_TOKEN for this session.')));
36
+ child.on('close', code => code === 0 ? resolve(output) : reject(new Error('Could not access the Windows protected credential. Log in again.')));
37
+ child.stdin.on('error', () => {});
38
+ child.stdin.end(value);
39
+ });
40
+ }
41
+ export async function readConfig(env = process.env, { secret = true } = {}) {
42
+ let saved;
43
+ for (const path of [join(configDirectory(env), 'credentials.json'), legacyCredentialPath(env)].filter(Boolean)) {
44
+ try { saved = JSON.parse(await readFile(path, 'utf8')); break; }
45
+ catch (error) { if (error.code === 'ENOENT') continue; throw new Error('Could not read CLI credentials. Run login again.'); }
46
+ }
47
+ if (!saved) return {};
48
+ if (!secret) return { server: saved.server };
49
+ if (saved.storage === 'dpapi') saved.token = await protect(saved.token, true);
50
+ return saved;
51
+ }
52
+ export async function saveConfig({ server, token }, env = process.env) {
53
+ const directory = configDirectory(env);
54
+ await mkdir(directory, { recursive: true, mode: 0o700 });
55
+ const saved = process.platform === 'win32' ? { server, token: await protect(token), storage: 'dpapi' } : { server, token, storage: 'file' };
56
+ const temporary = join(directory, `credentials.${randomUUID()}.tmp`);
57
+ try {
58
+ await writeFile(temporary, JSON.stringify(saved) + '\n', { flag: 'wx', mode: 0o600 });
59
+ await rename(temporary, join(directory, 'credentials.json'));
60
+ } finally { await rm(temporary, { force: true }); }
61
+ }
62
+ export async function clearConfig(env = process.env) {
63
+ await rm(join(configDirectory(env), 'credentials.json'), { force: true });
64
+ const legacy = legacyCredentialPath(env);
65
+ if (legacy) await rm(legacy, { force: true });
66
+ }