strckro 0.0.0-stage → 0.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.
@@ -0,0 +1,519 @@
1
+ # Strckro CLI — เอกสารการใช้งาน
2
+
3
+ เวอร์ชัน CLI: `0.2.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.2.0.tgz` ไปยังเครื่องอื่นแล้วรัน:
51
+
52
+ ```sh
53
+ npm install --global ./strckro-0.2.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
+ ## 6. สร้างและซิงก์โปรเจค
192
+
193
+ ### ดูรายการและสถานะ
194
+
195
+ ```sh
196
+ skctl projects list --json
197
+ skctl projects status my-app --json
198
+ ```
199
+
200
+ ### สร้างหรืออัปเดตจากไฟล์
201
+
202
+ สร้าง `project.json` เช่น:
203
+
204
+ ```json
205
+ {
206
+ "name": "My app",
207
+ "slug": "my-app",
208
+ "organizationId": "ORGANIZATION_ID_FROM_WHOAMI",
209
+ "repository": "https://github.com/your-org/your-app.git",
210
+ "branch": "main",
211
+ "directory": "/",
212
+ "runtime": "node",
213
+ "nodeMajor": 24,
214
+ "buildScript": "build",
215
+ "startScript": "start"
216
+ }
217
+ ```
218
+
219
+ ```sh
220
+ skctl whoami --json
221
+ skctl projects sync --file project.json --json
222
+ ```
223
+
224
+ ใช้ organization ID จากผล `whoami` หาก slug ยังไม่มีจะสร้างโปรเจค หากมีแล้วจะอัปเดต configuration และ sync source จึงควรตรวจเนื้อหาไฟล์ก่อนใช้กับโปรเจคเดิม
225
+
226
+ Private repository ใช้ `credentialId` ของ Git credential ที่สร้างบน Portal ไว้แล้ว ส่วน runtime และฟิลด์เพิ่มเติมใช้ contract เดียวกับ `POST /api/projects/sync` ของ backend
227
+
228
+ CLI deploy ใช้ Git source บน Portal ไม่อัปโหลด working directory จากเครื่องที่รัน CLI
229
+
230
+ ## 7. Deployment และ logs
231
+
232
+ | งาน | คำสั่ง |
233
+ | --- | --- |
234
+ | Deploy source ที่ sync แล้ว | `skctl deploy my-app` |
235
+ | Sync Git ก่อน deploy | `skctl deploy my-app --sync` |
236
+ | รอผล deployment | `skctl deploy my-app --sync --wait --timeout 300` |
237
+ | ดู job | `skctl jobs status JOB_ID --json` |
238
+ | ดูประวัติ deployment | `skctl deployments list [SLUG] --json` |
239
+ | อ่าน runtime logs | `skctl logs my-app --json` |
240
+ | Restart | `skctl restart my-app` |
241
+ | Rollback | `skctl rollback my-app --release RELEASE_ID` |
242
+
243
+ `--wait` ใช้กับ `deploy` หากครบเวลาที่กำหนด CLI จะแจ้ง job ID ให้ตรวจต่อ การหยุดรอของ CLI ไม่ได้ยกเลิก deployment บน server
244
+
245
+ หาก sync สร้าง deployment job ผ่าน auto-deploy แล้ว `deploy --sync` จะใช้ job นั้น โปรเจค web ต้องมี ENV อย่างน้อยหนึ่ง key ตาม validation ของ backend และการ deploy ยังคงผ่าน deployment queue/health checks เดิม
246
+
247
+ ## 8. Domain และ TLS
248
+
249
+ ```sh
250
+ skctl domains list my-app --json
251
+ skctl domains set my-app --domains app.example.com,www.example.com
252
+ skctl domains check my-app --hostname app.example.com --json
253
+ skctl domains verify my-app --json
254
+ ```
255
+
256
+ `domains set` **แทนที่รายชื่อโดเมนทั้งหมด** ของโปรเจค ไม่ใช่เพิ่มโดเมนเดียวเข้าไปในรายการเดิม
257
+
258
+ หากต้องการลบทั้งหมด:
259
+
260
+ ```sh
261
+ skctl domains clear my-app --sync
262
+ ```
263
+
264
+ `--sync` ใช้กับ `domains set` หรือ `domains clear` เพื่อเรียกการ sync เดิมของ Portal ส่วน `domains verify` ตรวจ DNS และเรียก workflow Nginx/TLS
265
+
266
+ CLI จัดการ domain configuration บน Portal ไม่สร้าง DNS records ที่ผู้ให้บริการ DNS โหมด demo จะไม่ตั้ง Nginx/TLS จริง และเมื่อ backend ตอบ `ok:false` CLI จะ exit `1` พร้อมผลที่ตรวจต่อได้
267
+
268
+ ## 9. Environment variables
269
+
270
+ ### ดูชื่อ key และ revision
271
+
272
+ ```sh
273
+ skctl env keys my-app --json
274
+ ```
275
+
276
+ คำสั่งนี้ไม่คืนค่า secret ใช้สิทธิ์ `env.read` หรือ `env.write`
277
+
278
+ ### ตั้งค่า key เดียว
279
+
280
+ ```sh
281
+ secret-command | skctl env set API_KEY my-app --stdin
282
+ skctl env set API_KEY my-app --file ./secret.txt
283
+ skctl env set OPTIONAL my-app --empty
284
+ ```
285
+
286
+ เลือกเพียงหนึ่ง option ระหว่าง `--stdin`, `--file` และ `--empty` ค่าต้องอยู่บรรทัดเดียว โดย CLI ลบ newline ท้ายหนึ่งบรรทัดเมื่อรับผ่าน stdin/file
287
+
288
+ ### ลบ key
289
+
290
+ ```sh
291
+ skctl env unset OLD_KEY my-app
292
+ ```
293
+
294
+ ### Import หลาย key
295
+
296
+ ไฟล์ `.env.production` ตัวอย่าง:
297
+
298
+ ```dotenv
299
+ NODE_ENV=production
300
+ APP_NAME=My application
301
+ OPTIONAL=
302
+ ```
303
+
304
+ ```sh
305
+ skctl env import my-app --file .env.production --json
306
+ ```
307
+
308
+ Import merge เฉพาะ keys ที่อยู่ในไฟล์ ไม่ลบ keys อื่นบน server
309
+
310
+ ### Export ค่าไปยังไฟล์
311
+
312
+ ```sh
313
+ skctl env pull my-app --file .env.export
314
+ ```
315
+
316
+ ต้องมี `env.read` แยกต่างหาก สร้างไฟล์ใหม่เท่านั้นและไม่พิมพ์ค่าลง stdout Token จาก default browser login ไม่มี scope นี้ ต้องเลือกเพิ่มเมื่อต้องการอ่านค่า
317
+
318
+ ### รูปแบบและการแก้พร้อมกัน
319
+
320
+ - ชื่อ key ใช้ตัวพิมพ์ใหญ่ ตัวเลข และ underscore โดยเริ่มด้วยตัวอักษรหรือ underscore
321
+ - Dotenv เป็น literal `KEY=value` บรรทัดเดียว ไม่ขยาย `$VARIABLE` หรือ shell expressions และรักษา quote ตามที่เขียน
322
+ - ENV บน backend จำกัด 128 KB
323
+ - การเขียนแก้เฉพาะ keys ที่ส่งและรักษา comment/keys อื่น
324
+ - Backend ตรวจ revision ภายใน transaction หากมีคนแก้แทรกจะตอบ HTTP `412` / exit `5` โดย CLI ไม่ retry ทับอัตโนมัติ
325
+ - ENV ที่บันทึกมีผลกับ deployment ครั้งถัดไป ไม่ deploy ให้อัตโนมัติจากคำสั่ง env
326
+
327
+ ระบุ revision เองได้:
328
+
329
+ ```sh
330
+ skctl env keys my-app --json
331
+ skctl env set API_KEY my-app --file ./secret.txt --revision REVISION_FROM_KEYS
332
+ ```
333
+
334
+ ## 10. ใช้กับ AI agents และ CI
335
+
336
+ AI ใช้ `skctl --help` เพื่อค้นคำสั่ง แล้วเติม `--json` เมื่อเรียก operation เพื่ออ่านผลได้แน่นอน ไม่ต้องต่อ API เอง
337
+
338
+ ตัวอย่างคำสั่งงานที่ให้ AI:
339
+
340
+ > ใช้ skctl ตรวจสถานะ my-app ด้วย --json จากนั้น import ENV จาก .env.production แล้ว deploy ด้วย --sync --wait --timeout 300 และรายงานผล job
341
+
342
+ ### PowerShell
343
+
344
+ ตัวอย่างนี้สมมติว่า secret manager หรือ CI ฉีด `STRCKRO_TOKEN` ให้ process แล้ว:
345
+
346
+ ```powershell
347
+ $env:STRCKRO_SERVER = 'https://portal.example.com'
348
+ $env:STRCKRO_PROJECT = 'my-app'
349
+
350
+ skctl projects status --json
351
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
352
+
353
+ skctl env import --file .env.production --json
354
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
355
+
356
+ skctl deploy --sync --wait --timeout 300 --json
357
+ exit $LASTEXITCODE
358
+ ```
359
+
360
+ ### Bash
361
+
362
+ ```sh
363
+ set -e
364
+ export STRCKRO_SERVER=https://portal.example.com
365
+ export STRCKRO_PROJECT=my-app
366
+ # STRCKRO_TOKEN is injected by your CI secret store.
367
+
368
+ skctl projects status --json
369
+ skctl env import --file .env.production --json
370
+ skctl deploy --sync --wait --timeout 300 --json
371
+ ```
372
+
373
+ Operation ที่ใช้ `--json` คืน JSON object เดียวทาง stdout ลิงก์ login อยู่ stderr ส่วน `--help` และ `--version` เป็นข้อความ ไม่มีค่า ENV ในผลการเขียน
374
+
375
+ ตัวอย่าง error JSON:
376
+
377
+ ```json
378
+ {
379
+ "ok": false,
380
+ "error": {
381
+ "code": "CONFLICT",
382
+ "message": "Environment changed. Read the latest revision and retry.",
383
+ "status": 412
384
+ }
385
+ }
386
+ ```
387
+
388
+ ## 11. สิทธิ์ของ token
389
+
390
+ Token ต้องผ่านทั้ง scope ของ token และสิทธิ์ปัจจุบันของบัญชีในองค์กรนั้น การปิดบัญชี เปลี่ยนรหัสผ่าน ถอน token หรือ token หมดอายุทำให้ใช้งานไม่ได้ การถอนสิทธิ์องค์กรมีผลกับ request ถัดไปและตรวจซ้ำก่อน mutation commit
391
+
392
+ | Scope | ใช้ทำอะไร |
393
+ | --- | --- |
394
+ | `project.view` | ดูรายการและสถานะโปรเจค |
395
+ | `project.create` | สร้างโปรเจคผ่าน sync |
396
+ | `project.configure` | เปลี่ยน configuration ของโปรเจคเดิมผ่าน sync |
397
+ | `source.sync` | Sync source ของโปรเจคเดิม |
398
+ | `deploy.start` | Deploy หรือ restart |
399
+ | `deploy.rollback` | Rollback |
400
+ | `domains.manage` | ตั้งค่า ตรวจและ verify domain |
401
+ | `env.read` | อ่านค่า ENV และ export |
402
+ | `env.write` | แก้ ENV และอ่าน metadata/revision |
403
+ | `logs.read` | อ่าน runtime logs และ deployment jobs |
404
+ | `credentials.use` | ผูกหรือเปลี่ยน Git credential ที่ใช้ sync |
405
+
406
+ Token ไม่สามารถจัดการบัญชี/สิทธิ์ผู้ใช้ ออก token ต่อ จัดการ Git credentials หรือติดตั้ง host tools ส่วน Monitor token เดิมยังคงอ่านอย่างเดียวและใช้แทน CLI token ไม่ได้
407
+
408
+ ## 12. Config และการเก็บ credential
409
+
410
+ | Environment variable | ความหมาย |
411
+ | --- | --- |
412
+ | `STRCKRO_SERVER` | Origin ของ Portal |
413
+ | `STRCKRO_TOKEN` | Token สำหรับ process/CI โดยไม่ต้อง login บันทึกลงเครื่อง |
414
+ | `STRCKRO_PROJECT` | Default project slug |
415
+ | `STRCKRO_CONFIG_DIR` | เปลี่ยนโฟลเดอร์เก็บ credential |
416
+ | `XDG_CONFIG_HOME` | Base config directory หากไม่ได้กำหนด `STRCKRO_CONFIG_DIR` |
417
+
418
+ ค่าเริ่มต้นอยู่ที่ `$XDG_CONFIG_HOME/strckro/credentials.json` หรือ `~/.config/strckro/credentials.json`
419
+
420
+ Windows เข้ารหัส token ด้วย DPAPI ผูกกับ OS user ปัจจุบัน ส่วน Linux/macOS เก็บไฟล์สิทธิ์ `0600` ในโฟลเดอร์ที่สร้างด้วยสิทธิ์ `0700` Server เก็บเฉพาะ hash ของ token/device secret และ ENV ยังคงเข้ารหัสด้วย vault ของ Portal
421
+
422
+ ## 13. Global options และ exit codes
423
+
424
+ | Option | ความหมาย |
425
+ | --- | --- |
426
+ | `--server URL` | เลือก Portal origin |
427
+ | `--project SLUG` | เลือกโปรเจค |
428
+ | `--json` | คืน operation result เป็น JSON |
429
+ | `--timeout SECONDS` | Request timeout 1–3600 วินาที ค่าเริ่มต้น 30; ใช้เป็นเวลารอ deploy เมื่อมี `--wait` ด้วย |
430
+ | `--help` | แสดงคำสั่ง |
431
+ | `--version` | แสดงเวอร์ชัน CLI |
432
+
433
+ | Exit code | ความหมาย |
434
+ | --- | --- |
435
+ | `0` | สำเร็จ |
436
+ | `1` | API หรือ deployment ไม่สำเร็จ รวม HTTP สำเร็จที่มี `ok:false` |
437
+ | `2` | Input, option หรือ configuration ไม่ถูกต้อง |
438
+ | `3` | ไม่มี credential หรือ token ใช้ไม่ได้ |
439
+ | `4` | ไม่มีสิทธิ์ |
440
+ | `5` | ENV revision เปลี่ยน |
441
+ | `6` | Network error หรือรอ deployment จนครบเวลา |
442
+
443
+ ## 14. แก้ปัญหาเบื้องต้น
444
+
445
+ | อาการ | วิธีตรวจ |
446
+ | --- | --- |
447
+ | หา `skctl` ไม่พบ | รัน `npm link` ในโฟลเดอร์ CLI และตรวจ npm global bin ใน PATH |
448
+ | Login พบ endpoint 404 | ตรวจ URL และอัปเดต backend ให้มี CLI API |
449
+ | Exit 3 | ตรวจ `STRCKRO_TOKEN`, วันหมดอายุ/การถอน token หรือ login ใหม่ |
450
+ | Exit 4 | ตรวจ scope ของ token และสิทธิ์บัญชีในองค์กร/โปรเจค |
451
+ | ENV pull ไม่ได้ แต่เขียนได้ | เพิ่ม `env.read` ใน token และตรวจสิทธิ์บัญชี |
452
+ | Exit 5 | อ่าน keys/revision ใหม่ ตรวจการเปลี่ยนแปลง แล้วค่อยส่งการแก้อีกครั้ง |
453
+ | Deploy แจ้งให้ sync ก่อน | ใช้ `deploy --sync` หรือ `projects sync --file project.json` |
454
+ | Domain verify ยัง pending | ตรวจ DNS, การ deploy และความพร้อมของ Nginx/TLS บน host |
455
+ | Export/init แจ้งไฟล์มีอยู่แล้ว | เลือก path ใหม่ หรือจัดการไฟล์เดิมก่อน คำสั่งไม่เขียนทับให้อัตโนมัติ |
456
+
457
+ ## 15. API สำหรับผู้พัฒนาต่อ
458
+
459
+ CLI เรียก backend เป็นผู้ตรวจสิทธิ์ บันทึก เข้ารหัส และ audit ส่วน client ดูแล input, credential และ output
460
+
461
+ | Method | Endpoint | Authentication |
462
+ | --- | --- | --- |
463
+ | `POST` | `/api/cli/device/start` | Anonymous, จำกัดอัตราการสร้างคำขอ |
464
+ | `POST` | `/api/cli/device/poll` | Device secret, polling interval 2 วินาที |
465
+ | `GET`, `POST` | `/api/cli/devices/:userCode` | Browser session; POST ต้องมี CSRF |
466
+ | `GET`, `POST` | `/api/cli/tokens` | Browser session; POST ต้องมี CSRF |
467
+ | `DELETE` | `/api/cli/tokens/:id` | Browser session และ CSRF |
468
+ | `GET` | `/api/access` | CLI bearer หรือ browser session |
469
+ | `GET` | `/api/projects/:slug/environment/metadata` | Token scope/สิทธิ์ `env.read` หรือ `env.write` |
470
+ | `PATCH` | `/api/projects/:slug/environment` | Token scope/สิทธิ์ `env.write` |
471
+
472
+ Existing project endpoints รับ `Authorization: Bearer <CLI_TOKEN>` ผ่าน allow-list แยกตาม action โดย browser session ยังใช้การตรวจ CSRF เดิม
473
+
474
+ ตัวอย่าง ENV patch body:
475
+
476
+ ```json
477
+ {
478
+ "revision": "REVISION_FROM_METADATA",
479
+ "set": {
480
+ "APP_NAME": "My app",
481
+ "OPTIONAL": ""
482
+ },
483
+ "unset": ["OLD_KEY"]
484
+ }
485
+ ```
486
+
487
+ Backend ตรวจ revision และเขียนใน transaction เดียว การส่ง key ซ้ำระหว่าง `set` กับ `unset` จะถูกปฏิเสธ และ response ไม่คืนค่า secret ที่เขียน
488
+
489
+ ## 16. ทดสอบการพัฒนา
490
+
491
+ ### การย้ายจาก CLI เดิม
492
+
493
+ แพ็กเกจใหม่คือ `strckro` คำสั่ง `skctl` มี alias `strckro` ใช้แทนกันได้ ยังคงรองรับตัวแปร `DASHBOARD_*` แต่ `STRCKRO_*` จะมีลำดับสูงกว่า อ่าน `dpctl.json` เมื่อไม่มี `strckro.json` และอ่าน credential เก่าในโฟลเดอร์ `dpctl` เมื่อไม่มี config override การ login ใหม่บันทึกในโฟลเดอร์ `strckro`; logout ล้าง credential default ทั้งสองที่
494
+
495
+ ### CLI
496
+
497
+ ```powershell
498
+ cd E:\dashboard-control-cli
499
+ npm test
500
+ ```
501
+
502
+ ### ทดสอบ CLI คู่กับ backend
503
+
504
+ ```powershell
505
+ $env:DASHBOARD_BACKEND_PATH = 'E:\dashboard-control'
506
+ npm test
507
+ ```
508
+
509
+ เมื่อไม่กำหนด `DASHBOARD_BACKEND_PATH` จะข้าม paired integration test
510
+
511
+ ### Backend
512
+
513
+ ```powershell
514
+ cd E:\dashboard-control
515
+ node --test test/cli-api.test.mjs
516
+ npm run docs:check
517
+ ```
518
+
519
+ 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,64 @@
1
+ # เผยแพร่ Strckro CLI บน npm
2
+
3
+ ชื่อแพ็กเกจ: `strckro`
4
+ คำสั่งหลังติดตั้ง: `skctl`
5
+ Registry: `https://registry.npmjs.org/`
6
+ การเข้าถึง: public
7
+
8
+ ## ตรวจแพ็กเกจก่อนเผยแพร่
9
+
10
+ ```sh
11
+ npm ci
12
+ npm test
13
+ npm run test:package
14
+ npm publish --dry-run
15
+ ```
16
+
17
+ `test:package` สร้าง tarball จริง ตรวจว่าไฟล์ตรงกับ allow-list แล้วติดตั้งด้วย npm ใน global prefix ชั่วคราว ทดสอบ launcher `skctl --version` และ help จาก working directory อื่น โดยไม่แก้ credential หรือ CLI ที่ติดตั้งแบบ global ของผู้ใช้
18
+
19
+ แพ็กเกจมีเฉพาะ runtime, `README.md`, `DOCUMENTATION.md`, `PUBLISHING.md`, `LICENSE` และ `package.json` ไม่รวม tests, scripts, `.env`, `.npmrc`, token หรือ local project configuration
20
+
21
+ `prepublishOnly` รัน tests และ package smoke อีกครั้งก่อน publish ส่วนเวอร์ชันที่ CLI แสดงอ่านจาก `package.json` โดยตรง
22
+
23
+ ## Login และ publish
24
+
25
+ ```sh
26
+ npm login --auth-type=web --registry=https://registry.npmjs.org/
27
+ npm whoami --registry=https://registry.npmjs.org/
28
+ npm run publish:npm
29
+ ```
30
+
31
+ ทำ browser login และการยืนยัน 2FA ตามที่ npm แสดง ไม่ต้องใส่ password, token หรือรหัส 2FA ลง source หรือส่งให้ผู้อื่น บัญชีที่ publish ต้องมีสิทธิ์ในชื่อแพ็กเกจนี้ หาก npm ไม่ให้ใช้ชื่อดังกล่าว ให้เลือกชื่อใหม่แล้วตรวจ package อีกครั้ง
32
+
33
+ เมื่อตีพิมพ์ `name@version` สำเร็จแล้วไม่สามารถนำเวอร์ชันเดิมมาตีพิมพ์ซ้ำได้ สำหรับ release ถัดไป:
34
+
35
+ ```sh
36
+ npm version patch --no-git-tag-version
37
+ npm run publish:npm
38
+ ```
39
+
40
+ ## ตรวจจาก registry หลัง publish
41
+
42
+ ```sh
43
+ npm view strckro version
44
+ npm install --global strckro
45
+ skctl --version
46
+ ```
47
+
48
+ เครื่องอื่นติดตั้งได้ด้วย `npm install --global strckro` หรือใช้ชั่วคราวด้วย `npx --package strckro skctl --help` หลัง publish สำเร็จ
49
+
50
+ ## แจก tarball โดยไม่ผ่าน registry
51
+
52
+ ```sh
53
+ npm run pack:release
54
+ ```
55
+
56
+ จะได้ `strckro-0.2.0.tgz` ตามเวอร์ชันปัจจุบัน ส่งไฟล์นี้ไปยังเครื่องอื่นแล้วติดตั้งได้ทันที:
57
+
58
+ ```sh
59
+ npm install --global ./strckro-0.2.0.tgz
60
+ skctl --version
61
+ skctl login --server https://portal.example.com --no-browser
62
+ ```
63
+
64
+ อ้างอิง: [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,163 @@
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.2.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 --json
67
+ skctl init --project my-app
68
+ skctl projects status --json
69
+ skctl deployments list --json
70
+ skctl projects sync --file project.json
71
+ skctl env import --file .env.production
72
+ skctl deploy --sync --wait --timeout 300
73
+ skctl logs
74
+ skctl restart
75
+ skctl rollback --release RELEASE_ID
76
+ skctl jobs status JOB_ID
77
+ ```
78
+
79
+ `init` สร้าง `strckro.json` เก็บแค่ server/project ไม่มี token ไม่เขียนทับไฟล์เดิม อาจ commit ให้ทีมใช้ร่วมกันได้ ลำดับเลือก server: `--server`, `STRCKRO_SERVER`, `strckro.json`, credential ที่บันทึก; project: `--project`, positional slug, `STRCKRO_PROJECT`, `strckro.json`
80
+
81
+ สร้างโปรเจคด้วย `project.json` ตาม `POST /api/projects/sync` เช่น:
82
+
83
+ ```json
84
+ {
85
+ "name": "My app",
86
+ "slug": "my-app",
87
+ "organizationId": "ID_FROM_WHOAMI",
88
+ "repository": "https://github.com/your-org/your-app.git",
89
+ "branch": "main",
90
+ "directory": "/",
91
+ "runtime": "node",
92
+ "nodeMajor": 24,
93
+ "startScript": "start",
94
+ "buildScript": "build"
95
+ }
96
+ ```
97
+
98
+ Deploy ใช้ Git source บน Portal ไม่อัปโหลด working directory; `--sync` ดึง source ก่อนและใช้ job จาก auto-deploy ถ้ามี Host ต้องติดตั้ง Git/runtime และตั้ง Git identity ตามปกติ Private repository ใช้ `credentialId` ที่สร้างบน Portal ไว้แล้ว runtime ต่าง ๆ ใช้ validation/deployment queue เดิม
99
+
100
+ ## Domain + TLS
101
+
102
+ ```sh
103
+ skctl domains set --domains app.example.com,www.example.com
104
+ skctl domains check --hostname app.example.com
105
+ skctl domains verify
106
+ skctl domains list
107
+ skctl domains clear --sync
108
+ ```
109
+
110
+ `set` แทนที่รายชื่อทั้งหมด `clear` ลบทั้งหมด `verify` ตรวจ DNS และเรียก Nginx/TLS workflow เดิม DNS ต้องชี้ server ก่อน CLI ไม่สร้าง records ที่ผู้ให้บริการ DNS โหมด demo ไม่ตั้ง Nginx/TLS จริง
111
+
112
+ ## Environment
113
+
114
+ ```sh
115
+ skctl env keys
116
+ secret-command | skctl env set API_KEY --stdin
117
+ skctl env set API_KEY --file ./secret.txt
118
+ skctl env set OPTIONAL --empty
119
+ skctl env unset OLD_KEY
120
+ skctl env import --file .env.production
121
+ skctl env pull --file .env.export
122
+ ```
123
+
124
+ `set/import` แก้เฉพาะ keys ที่ส่ง รักษา comment/keys อื่น `unset` ลบ key `--empty` ตั้งค่าว่าง ทุกครั้งเป็น atomic transaction และตรวจ revision ถ้ามีคนแก้แทรกจะ exit `5` ไม่ retry ทับอัตโนมัติ ส่ง `--revision REV` เพื่อกำหนด precondition เองได้ ENV มีผลเมื่อ deploy ครั้งถัดไป
125
+
126
+ 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
127
+
128
+ ## AI / CI
129
+
130
+ ให้ AI ใช้ `skctl --help` ค้นคำสั่ง และเติม `--json` stdout เป็น JSON object เดียว ส่วนลิงก์ login อยู่ stderr ไม่ต้องต่อ HTTP/cookie/CSRF เอง
131
+
132
+ ```sh
133
+ export STRCKRO_SERVER=https://portal.example.com
134
+ export STRCKRO_TOKEN="$(your-secret-manager read portal-token)"
135
+ export STRCKRO_PROJECT=my-app
136
+ skctl projects status --json
137
+ skctl env import --file .env.production --json
138
+ skctl deploy --sync --wait --timeout 300 --json
139
+ ```
140
+
141
+ Browser login ค่าเริ่มต้นเปิด workflow โปรเจคทั้งหมด **ยกเว้น `env.read`** สมาชิกที่สิทธิ์น้อยกว่าให้เลือก scope ตรงกับสิทธิ์ เช่น:
142
+
143
+ ```sh
144
+ skctl login --server https://portal.example.com --no-browser --project my-app --scope project.view,source.sync,deploy.start,logs.read
145
+ ```
146
+
147
+ 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
148
+
149
+ ## ย้ายจาก dashboard-control-cli
150
+
151
+ แพ็กเกจใหม่ชื่อ `strckro` ใช้ `skctl` หรือ alias `strckro` แทน `dpctl` ตัวแปร `STRCKRO_*` มีลำดับสูงกว่า `DASHBOARD_*` เดิมที่ยังรองรับ อ่าน `dpctl.json` ได้เมื่อไม่มี `strckro.json` และอ่าน credential เก่าจาก config directory `dpctl` ได้เมื่อไม่มี override โฟลเดอร์ Login ครั้งถัดไปจะเก็บในโฟลเดอร์ `strckro` โดยยังใช้ DPAPI บน Windows
152
+
153
+ 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`
154
+
155
+ ## ทดสอบ
156
+
157
+ ```powershell
158
+ npm test
159
+ $env:DASHBOARD_BACKEND_PATH = 'E:\dashboard-control'
160
+ npm test
161
+ ```
162
+
163
+ กำหนด `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.2.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,271 @@
1
+ import { parseArgs } 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
25
+ projects status [SLUG]
26
+ projects sync --file project.json Create/update and sync a Git project
27
+ deployments list [SLUG] List deployment history and active jobs
28
+ deploy [SLUG] [--sync] [--wait] Deploy the synced Git revision
29
+ restart [SLUG]
30
+ rollback [SLUG] --release ID
31
+ jobs status ID
32
+ logs [SLUG]
33
+
34
+ Domains:
35
+ domains list [SLUG]
36
+ domains set [SLUG] --domains a.com,b.com [--sync]
37
+ domains clear [SLUG] Remove all configured domains
38
+ domains check [SLUG] --hostname a.com
39
+ domains verify [SLUG] Verify DNS and apply Nginx/TLS
40
+
41
+ Environment:
42
+ env keys [SLUG] Names and revision only
43
+ env set KEY [SLUG] --stdin|--file PATH|--empty
44
+ env unset KEY [SLUG]
45
+ env import [SLUG] --file .env Merge keys atomically
46
+ env pull [SLUG] --file PATH Export values; requires env.read
47
+
48
+ Global options:
49
+ --server URL --project SLUG --json --timeout SECONDS --help
50
+ --scope SCOPE,... --name LABEL --days 1..90 (login)
51
+ --revision REV (env writes; otherwise fetch the current revision)
52
+
53
+ Environment: STRCKRO_SERVER, STRCKRO_TOKEN, STRCKRO_PROJECT,
54
+ STRCKRO_CONFIG_DIR, XDG_CONFIG_HOME
55
+ Legacy DASHBOARD_* variables and dpctl.json remain supported.
56
+ Exit codes: 0 success; 1 API/deployment; 2 input; 3 authentication;
57
+ 4 permission denied; 5 stale environment; 6 network/timeout.
58
+ Deploy uses the repository configured on Portal; it does not upload local files.
59
+ Use --json for AI/CI: stdout contains one JSON object; progress goes to stderr.
60
+ `;
61
+
62
+ const stringOptions = ['server', 'token', 'project', 'scope', 'name', 'days', 'file', 'domains', 'hostname', 'release', 'revision', 'timeout'];
63
+ const booleanOptions = ['json', 'help', 'version', 'no-browser', 'token-stdin', 'stdin', 'empty', 'wait', 'sync'];
64
+ const options = Object.fromEntries([...stringOptions.map(key => [key, { type: 'string' }]), ...booleanOptions.map(key => [key, { type: 'boolean' }])]);
65
+ 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; };
66
+ const keyName = value => { if (!/^[A-Z_][A-Z0-9_]*$/.test(value || '')) throw new CliError('Specify an uppercase environment key.'); return value; };
67
+ const required = (value, flag) => { if (!value) throw new CliError(`Provide ${flag}.`); return value; };
68
+
69
+ async function stdinText(input) {
70
+ if (input.isTTY) throw new CliError('Pipe the value into stdin.');
71
+ const chunks = []; let size = 0;
72
+ 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)); }
73
+ return Buffer.concat(chunks).toString('utf8');
74
+ }
75
+ function openBrowser(url) {
76
+ return new Promise(resolve => {
77
+ const command = process.platform === 'win32' ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
78
+ const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore', windowsHide: true });
79
+ child.once('error', () => resolve(false));
80
+ child.once('spawn', () => { child.unref(); resolve(true); });
81
+ });
82
+ }
83
+ async function localProject(cwd) {
84
+ for (const file of ['strckro.json', 'dpctl.json']) {
85
+ try { return JSON.parse(await readFile(resolve(cwd, file), 'utf8')); }
86
+ catch (error) { if (error.code === 'ENOENT') continue; throw new CliError(`Invalid ${file}.`); }
87
+ }
88
+ return {};
89
+ }
90
+ // Literal dotenv document: preserve quotes, never expand shell expressions.
91
+ export function parseEnv(content) {
92
+ if (Buffer.byteLength(content) > 128 * 1024) throw new CliError('Environment file exceeds 128 KB.');
93
+ const result = Object.create(null);
94
+ for (const line of content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n')) {
95
+ if (!line.trim() || line.trimStart().startsWith('#')) continue;
96
+ const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=(.*)$/);
97
+ if (!match || /[\r\0]/.test(line)) throw new CliError('Invalid environment file; use single-line KEY=value entries.');
98
+ if (Object.hasOwn(result, match[1])) throw new CliError(`Duplicate environment key: ${match[1]}.`);
99
+ result[match[1]] = match[2];
100
+ }
101
+ return result;
102
+ }
103
+
104
+ export async function run(argv, { env = process.env, cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr, stdin = process.stdin, browser = openBrowser } = {}) {
105
+ env = clientEnvironment(env);
106
+ let json = argv.includes('--json');
107
+ const print = result => stdout.write(JSON.stringify(result, null, json ? 0 : 2) + '\n');
108
+ try {
109
+ let parsed;
110
+ try { parsed = parseArgs({ args: argv, options, allowPositionals: true, strict: true }); }
111
+ catch { throw new CliError('Unknown or incomplete option. Run skctl --help.'); }
112
+ const { values: flags, positionals: args } = parsed;
113
+ json = Boolean(flags.json);
114
+ if (flags.help || !args.length && !flags.version) { stdout.write(HELP); return 0; }
115
+ if (flags.version) { stdout.write(version + '\n'); return 0; }
116
+ const [command, action, ...rest] = args;
117
+ // Logout does not need a working server or credential decryption.
118
+ 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; }
119
+ const supported = ['login', 'whoami', 'init', 'projects', 'deployments', 'deploy', 'restart', 'rollback', 'jobs', 'logs', 'domains', 'env'];
120
+ if (!supported.includes(command)) throw new CliError('Unknown command. Run skctl --help.');
121
+ const commandOptions = command === 'login' ? ['token', 'token-stdin', 'no-browser', 'scope', 'name', 'days']
122
+ : command === 'projects' && action === 'sync' ? ['file']
123
+ : command === 'deploy' ? ['sync', 'wait']
124
+ : command === 'rollback' ? ['release']
125
+ : command === 'domains' ? action === 'set' ? ['domains', 'sync'] : action === 'clear' ? ['sync'] : action === 'check' ? ['hostname'] : []
126
+ : command === 'env' ? action === 'set' ? ['stdin', 'file', 'empty', 'revision'] : action === 'unset' ? ['revision'] : action === 'import' ? ['file', 'revision'] : action === 'pull' ? ['file'] : [] : [];
127
+ 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.`);
128
+ const local = await localProject(cwd);
129
+ const requestedServer = flags.server || env.DASHBOARD_SERVER || local.server;
130
+ const needsCredential = command !== 'login' && !env.DASHBOARD_TOKEN;
131
+ let saved = !requestedServer || needsCredential ? await readConfig(env, { secret: false }) : {};
132
+ const server = normalizeServer(requestedServer || saved.server);
133
+ if (needsCredential && saved.server === server) saved = await readConfig(env);
134
+ const timeout = flags.timeout === undefined ? 30 : Number(flags.timeout);
135
+ if (!Number.isFinite(timeout) || timeout < 1 || timeout > 3600) throw new CliError('Timeout must be 1–3600 seconds.');
136
+ let token = env.DASHBOARD_TOKEN || (saved.server === server ? saved.token : undefined);
137
+ let api = createClient({ server, token, timeout: timeout * 1000 });
138
+ const project = positional => slug(flags.project || positional || env.DASHBOARD_PROJECT || local.project);
139
+ const route = value => `/api/projects/${encodeURIComponent(value)}`;
140
+ if (command === 'login') {
141
+ if (args.length > 1) throw new CliError('Usage: skctl login --server URL');
142
+ if (flags.token && flags['token-stdin']) throw new CliError('Choose --token or --token-stdin.');
143
+ if (flags['token-stdin'] || flags.token || env.DASHBOARD_TOKEN) {
144
+ token = flags['token-stdin'] ? (await stdinText(stdin)).trim() : flags.token || env.DASHBOARD_TOKEN;
145
+ } else {
146
+ token = undefined;
147
+ const days = flags.days === undefined ? 30 : Number(flags.days);
148
+ if (!Number.isInteger(days) || days < 1 || days > 90) throw new CliError('Days must be 1–90.');
149
+ 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) } : {}) } });
150
+ const link = new URL(device.verificationPath, server);
151
+ if (link.origin !== server || link.pathname !== '/settings/account') throw new CliError('Invalid login URL returned by Portal.', 'INVALID_RESPONSE');
152
+ stderr.write(`Open this link and verify code ${device.userCode}:\n${link.href}\n`);
153
+ if (!flags['no-browser']) await browser(link.href);
154
+ const deadline = Date.now() + device.expiresIn * 1000;
155
+ let interval = Math.max(2, device.interval || 2);
156
+ while (Date.now() < deadline) {
157
+ await delay(interval * 1000);
158
+ let polled;
159
+ try { polled = await api('/api/cli/device/poll', { method: 'POST', anonymous: true, body: { deviceCode: device.deviceCode } }); }
160
+ catch (error) { if (error.status === 429) { interval = Math.min(10, interval + 2); continue; } throw error; }
161
+ if (polled.status === 'complete') { token = polled.token; break; }
162
+ if (polled.status !== 'pending') throw new CliError('Unexpected login response.', 'INVALID_RESPONSE');
163
+ }
164
+ if (!token) throw new CliError('Login expired. Run login again.', 'AUTH_REQUIRED', 401);
165
+ }
166
+ if (!/^dp_[A-Za-z0-9_-]{43}$/.test(token || '')) throw new CliError('Use a CLI token issued in Portal Settings > Account.');
167
+ api = createClient({ server, token, timeout: timeout * 1000 });
168
+ const identity = await api('/api/access');
169
+ await saveConfig({ server, token }, env);
170
+ print({ ok: true, server, user: identity.user });
171
+ return 0;
172
+ }
173
+ let result;
174
+ if (command === 'whoami') {
175
+ if (args.length !== 1) throw new CliError('Usage: skctl whoami');
176
+ result = { server, ...await api('/api/access') };
177
+ } else if (command === 'deployments') {
178
+ if (action !== 'list' || rest.length > 1) throw new CliError('Usage: skctl deployments list [SLUG]');
179
+ const target = flags.project || rest[0] || env.DASHBOARD_PROJECT || local.project;
180
+ result = await api('/api/deployments' + (target ? `?project=${encodeURIComponent(slug(target))}` : ''));
181
+ } else if (command === 'init') {
182
+ if (args.length !== 1) throw new CliError('Usage: skctl init --project SLUG');
183
+ const target = project();
184
+ const { projects } = await api('/api/projects');
185
+ if (!projects.some(item => item.slug === target)) throw new CliError('Project not found.', 'API_ERROR', 404);
186
+ await writeFile(resolve(cwd, 'strckro.json'), JSON.stringify({ server, project: target }, null, 2) + '\n', { flag: 'wx' });
187
+ result = { ok: true, server, project: target, file: resolve(cwd, 'strckro.json') };
188
+ } else if (command === 'projects') {
189
+ 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');
190
+ if (action === 'sync') {
191
+ let body;
192
+ 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.'); }
193
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new CliError('Project file must be a JSON object.');
194
+ slug(body.slug);
195
+ result = await api('/api/projects/sync', { method: 'POST', body });
196
+ } else {
197
+ result = await api('/api/projects');
198
+ 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); }
199
+ }
200
+ } else if (['deploy', 'restart', 'rollback', 'logs'].includes(command)) {
201
+ if (args.length > 2) throw new CliError(`Usage: skctl ${command} [SLUG]`);
202
+ const target = project(action);
203
+ if (flags.sync) {
204
+ if (command !== 'deploy') throw new CliError('--sync applies to deploy.');
205
+ const { projects } = await api('/api/projects');
206
+ const existing = projects.find(item => item.slug === target);
207
+ if (!existing) throw new CliError('Project not found.', 'API_ERROR', 404);
208
+ result = await api('/api/projects/sync', { method: 'POST', body: { ...existing, domains: existing.domains?.hosts } });
209
+ }
210
+ if (!(command === 'deploy' && result?.job)) result = await api(`${route(target)}/${command}`, command === 'logs' ? {} : { method: 'POST', body: command === 'rollback' ? { releaseId: required(flags.release, '--release ID') } : {} });
211
+ if (flags.wait && result.job) {
212
+ const deadline = Date.now() + timeout * 1000;
213
+ while (['queued', 'running'].includes(result.job.status)) {
214
+ if (Date.now() >= deadline) throw new CliError(`Deployment is still running. Check skctl jobs status ${result.job.id}.`, 'TIMEOUT');
215
+ await delay(1000);
216
+ const polled = await api(`/api/jobs/${encodeURIComponent(result.job.id)}`);
217
+ result = { ...result, job: polled.job };
218
+ }
219
+ if (result.job.status !== 'succeeded') throw new CliError(`Deployment ${result.job.status}. Check skctl jobs status ${result.job.id}.`, 'DEPLOYMENT_FAILED');
220
+ }
221
+ } else if (command === 'jobs') {
222
+ if (action !== 'status' || rest.length !== 1 || !/^[a-f0-9-]{36}$/i.test(rest[0])) throw new CliError('Usage: skctl jobs status ID');
223
+ result = await api(`/api/jobs/${encodeURIComponent(rest[0])}`);
224
+ } else if (command === 'domains') {
225
+ if (!['list', 'set', 'clear', 'check', 'verify'].includes(action) || rest.length > 1) throw new CliError('Usage: skctl domains list|set|clear|check|verify [SLUG]');
226
+ const target = project(rest[0]);
227
+ if (action === 'list') {
228
+ const { projects } = await api('/api/projects');
229
+ const item = projects.find(item => item.slug === target);
230
+ if (!item) throw new CliError('Project not found.', 'API_ERROR', 404);
231
+ result = { project: target, domains: item.domains ?? { hosts: [] } };
232
+ } else {
233
+ const endpoint = ['set', 'clear'].includes(action) ? 'domains' : `domains/${action}`;
234
+ 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') } : {};
235
+ result = await api(`${route(target)}/${endpoint}`, { method: 'POST', body });
236
+ }
237
+ } else if (command === 'env') {
238
+ 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.');
239
+ const key = ['set', 'unset'].includes(action) ? keyName(rest[0]) : null;
240
+ const target = project(rest[key ? 1 : 0]);
241
+ const endpoint = `${route(target)}/environment`;
242
+ if (action === 'keys') result = await api(`${endpoint}/metadata`);
243
+ else if (action === 'pull') {
244
+ const file = resolve(cwd, required(flags.file, '--file PATH'));
245
+ const response = await api(endpoint);
246
+ await writeFile(file, response.environment.content, { flag: 'wx', mode: 0o600 });
247
+ result = { ok: true, file, keys: response.environment.variables.map(item => item.key) };
248
+ } else {
249
+ let set = {}, unset = [];
250
+ if (action === 'set') {
251
+ if ([flags.stdin, Boolean(flags.file), flags.empty].filter(Boolean).length !== 1) throw new CliError('Choose exactly one of --stdin, --file PATH, or --empty.');
252
+ const value = flags.empty ? '' : flags.stdin ? await stdinText(stdin) : await readFile(resolve(cwd, flags.file), 'utf8');
253
+ const cleaned = value.replace(/\r?\n$/, '');
254
+ if (/[\r\n\0]/.test(cleaned)) throw new CliError('Environment values must be single-line strings.');
255
+ set = { [key]: cleaned };
256
+ } else if (action === 'unset') unset = [key];
257
+ 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.'); }
258
+ const revision = flags.revision || (await api(`${endpoint}/metadata`)).environment.revision;
259
+ result = await api(endpoint, { method: 'PATCH', body: { revision, set, unset } });
260
+ }
261
+ }
262
+ print({ ok: true, ...result });
263
+ return result?.ok === false ? 1 : 0;
264
+ } catch (error) {
265
+ 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.';
266
+ const code = error instanceof CliError ? error.code : 'INVALID_INPUT';
267
+ if (json) print({ ok: false, error: { code, message, ...(error.status ? { status: error.status } : {}) } });
268
+ else stderr.write(`Error: ${message}\n`);
269
+ return error.status === 401 ? 3 : error.status === 403 ? 4 : error.status === 412 ? 5 : ['NETWORK_ERROR', 'TIMEOUT'].includes(code) ? 6 : code === 'INVALID_INPUT' ? 2 : 1;
270
+ }
271
+ }
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
+ }