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.
- package/DOCUMENTATION.md +532 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +66 -0
- package/README.md +167 -2
- package/bin/skctl.mjs +3 -0
- package/package.json +42 -4
- package/src/cli.mjs +322 -0
- package/src/client.mjs +16 -0
- package/src/config.mjs +66 -0
package/DOCUMENTATION.md
ADDED
|
@@ -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
|
-
#
|
|
1
|
+
# Strckro CLI (`skctl`)
|
|
2
2
|
|
|
3
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strckro",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|