@keystrokehq/cli 0.0.17 → 0.0.18
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/dist/{accept.handler-BPwp_UAE.mjs → accept.handler-B7QzdKCh.mjs} +4 -4
- package/dist/{admin-Bb9Hx-gO.mjs → admin-CYpulx_A.mjs} +11 -11
- package/dist/{agents-CbmvvOAx.mjs → agents-Co6Jy_N8.mjs} +10 -10
- package/dist/{api-jkf0TTgD.mjs → api-DsK8M-ZH.mjs} +1 -1
- package/dist/{api-keys-DJlyIf10.mjs → api-keys-BUCLzRv_.mjs} +6 -6
- package/dist/{auth-DpDEkJz7.mjs → auth-niNm-yNT.mjs} +12 -7
- package/dist/{auth.handler-u3qmoUX0.mjs → auth.handler-BTH-Qb00.mjs} +59 -26
- package/dist/{build-agents-DseUtzd4-VYWtIZy9.mjs → build-agents-DseUtzd4-CthuIecx.mjs} +6 -6
- package/dist/{build-metadata-C8Ra_Gi--BdoyLQMl.mjs → build-metadata-C8Ra_Gi--L3l8w0rh.mjs} +7 -7
- package/dist/{build-progress-BZivcVz4.mjs → build-progress-AR8xow4_.mjs} +2 -2
- package/dist/{build-tasks-GVuMLS0h-p08mMOyK.mjs → build-tasks-GVuMLS0h-CCxCqd02.mjs} +3 -3
- package/dist/{build-workflows-CV4tBo6S-knCnBKTc.mjs → build-workflows-CV4tBo6S-DhFBlp6m.mjs} +10 -10
- package/dist/{build.handler-BNSC_zhQ.mjs → build.handler-BmlXPhed.mjs} +7 -7
- package/dist/{clear-cache.handler-gr5VmEYB.mjs → clear-cache.handler-CTLQ1PIN.mjs} +3 -3
- package/dist/clear.handler-BDlwBzX4.mjs +68 -0
- package/dist/{clear.handler-CtOZ4aRn.mjs → clear.handler-C_pXAeBG.mjs} +3 -2
- package/dist/{commander-D15UZVjp.mjs → commander-BE37hxR3.mjs} +4 -4
- package/dist/{connect-DzSNDSmI.mjs → connect-C9NMD8Ky.mjs} +3 -3
- package/dist/{connect.handler-DRO05ak3.mjs → connect.handler-C7kysvhz.mjs} +5 -5
- package/dist/{context-B1L8pZsH.mjs → context-Bid-Rqj7.mjs} +49 -17
- package/dist/{create.handler-DF1Ye4nr.mjs → create.handler-Cp9CV6SN.mjs} +3 -3
- package/dist/{credential-env-map-B2nVJXPn.mjs → credential-env-map-BA4LNI7x.mjs} +6 -5
- package/dist/{credential-requirements-FtBk5JVB.mjs → credential-requirements-DrrQ9x9P.mjs} +3 -3
- package/dist/{credential-schema-mismatch-CfyBUMPS.mjs → credential-schema-mismatch-z74ud-YZ.mjs} +1 -1
- package/dist/{credentials-CiOwDS5y.mjs → credentials-DUkVbhvj.mjs} +1 -1
- package/dist/{credentials-VidBoOd7.mjs → credentials-fMfKVlEn.mjs} +7 -7
- package/dist/{current-deployment-workflow-BRUEdPrN.mjs → current-deployment-workflow-DiwUcKoB.mjs} +6 -6
- package/dist/{current.handler-QZQ-l84v.mjs → current.handler-eCR4nClu.mjs} +3 -3
- package/dist/{delete.handler-Bude0SVP.mjs → delete.handler-D4ElSAcr.mjs} +2 -2
- package/dist/{deploy-eshEEiP-.mjs → deploy-B7LRWcp6.mjs} +2 -2
- package/dist/{deploy-CJbVB7e2.mjs → deploy-DyZh--f7.mjs} +1 -1
- package/dist/{deploy-progress-DJHph1Fz.mjs → deploy-progress-DK87VKJ-.mjs} +2 -2
- package/dist/{deploy.handler-BxxWI7nV.mjs → deploy.handler-DVnH-Niv.mjs} +20 -20
- package/dist/{detect-env-access-CwkOYeYM-CZIixHeR.mjs → detect-env-access-CwkOYeYM-CNTyUzme.mjs} +1 -1
- package/dist/{diff-utils-4OQTpP5s.mjs → diff-utils-B0ED-Igv.mjs} +1 -1
- package/dist/{diff.handler-CzrKCj7N.mjs → diff.handler-lIA2pRBX.mjs} +7 -7
- package/dist/dist-CIInPRGh.mjs +1071 -0
- package/dist/{dist-FQYQ2FLm.mjs → dist-WFPTDQB3.mjs} +15 -15
- package/dist/{env.handler-B3YDQIVE.mjs → env.handler-BIzQLlmo.mjs} +10 -10
- package/dist/{error-boundary-CyLcinp1.mjs → error-boundary-B8cmSwJH.mjs} +3 -3
- package/dist/{file-metadata-DaPPpiTh.mjs → file-metadata-lrX05iRt.mjs} +1 -1
- package/dist/{iam-command-utils-ByLX0A-V.mjs → iam-command-utils-CSZj4XlH.mjs} +2 -2
- package/dist/{import-module--8x5SLum-DaUNACER.mjs → import-module--8x5SLum-D7EiPjwl.mjs} +6 -6
- package/dist/{init-CWFJdKNs.mjs → init-jaqNLGmB.mjs} +3 -3
- package/dist/{init.handler-BZSoM76V.mjs → init.handler-DamvbPEw.mjs} +8 -8
- package/dist/{inspect.handler-umc7of-r.mjs → inspect.handler-_UcN7dxE.mjs} +8 -8
- package/dist/{integration-catalog-BgT4mLzW.mjs → integration-catalog-m8tj_XlD.mjs} +3 -3
- package/dist/{integrations-DKtl_aES.mjs → integrations-DRL3JmC8.mjs} +6 -6
- package/dist/{invites-Cqi7iyIN.mjs → invites-VntHNMYk.mjs} +5 -5
- package/dist/{invites.list.handler-CErgY35S.mjs → invites.list.handler-CPl4QHfc.mjs} +4 -4
- package/dist/{invites.resend.handler-DRCRIA4F.mjs → invites.resend.handler-BAtb3AX4.mjs} +4 -4
- package/dist/{invites.revoke.handler-C0FZdAR0.mjs → invites.revoke.handler-qXOF1Vgx.mjs} +4 -4
- package/dist/keystroke.mjs +558 -207
- package/dist/{list-enrichment-C6u5eI0j.mjs → list-enrichment-DYvr3XDb.mjs} +3 -3
- package/dist/{list.handler-c-8RpgB9.mjs → list.handler-BLkQKiV1.mjs} +17 -16
- package/dist/{list.handler-CBEXiTAK.mjs → list.handler-BdRsjRlf.mjs} +3 -3
- package/dist/{list.handler-DYdNWjgk.mjs → list.handler-CEjKSezx.mjs} +4 -4
- package/dist/{list2.handler-T5v4EK20.mjs → list.handler-CJUFdmaU.mjs} +7 -7
- package/dist/{list.handler-Cr_DFAae.mjs → list.handler-C_iBLBmS.mjs} +3 -3
- package/dist/{list.handler-FlchXrKz.mjs → list.handler-DUz1bJ4x.mjs} +4 -4
- package/dist/{list.handler-D-YFoKLU.mjs → list.handler-pHnPFep8.mjs} +7 -7
- package/dist/{listen-rHLiCWbn.mjs → listen-DLGZEQRL.mjs} +3 -3
- package/dist/{listen.handler-B9T58yAj.mjs → listen.handler-kaAvYk-B.mjs} +4 -4
- package/dist/logs-CcYqFKRU.mjs +58 -0
- package/dist/logs.handler-DyRoevtO.mjs +53 -0
- package/dist/{logs.handler-DGcGN2qb.mjs → logs.handler-lboRKNoE.mjs} +4 -4
- package/dist/{members.add.handler-DmYI43rZ.mjs → members.add.handler-DBydP0SR.mjs} +4 -4
- package/dist/{members.invite.handler-B_KVxv5m.mjs → members.invite.handler-D4-7fiYC.mjs} +4 -4
- package/dist/{members.list.handler-BtuuIgQS.mjs → members.list.handler-Z4cIbcNg.mjs} +4 -4
- package/dist/{members.remove.handler-Lvg-CqVv.mjs → members.remove.handler-J56D83O7.mjs} +4 -4
- package/dist/{members.update.handler-D-8izeso.mjs → members.update.handler-B5rBv6dt.mjs} +4 -4
- package/dist/{normalize-path-CojS-CgQ-DFTvyA27.mjs → normalize-path-CojS-CgQ-D4wSBHgG.mjs} +1 -1
- package/dist/{org-DUCts2MV.mjs → org-DGS91uc-.mjs} +17 -17
- package/dist/{orgs.create.handler-vXQgDJZ_.mjs → orgs.create.handler-B4naNUSN.mjs} +4 -4
- package/dist/{orgs.get.handler-D_Jfl18x.mjs → orgs.get.handler-Ci_JrT08.mjs} +4 -4
- package/dist/{orgs.list.handler-BNjoTJvV.mjs → orgs.list.handler-CJ2byIEj.mjs} +4 -4
- package/dist/{output-CGdYhH0p.mjs → output-BWcVRt-T.mjs} +1 -1
- package/dist/paths-JzzFkXQA-CEipIeVl.mjs +36 -0
- package/dist/{paused.handler-ST9dCe8E.mjs → paused.handler-BQSQvQhB.mjs} +3 -3
- package/dist/{projects-CbquwUlm.mjs → projects-D90_uEC2.mjs} +5 -5
- package/dist/{projects-DfaG_3WP.mjs → projects-fWvIJQ80.mjs} +1 -1
- package/dist/{register.handler-BAx0IC-u.mjs → register.handler-CleQJhtQ.mjs} +2 -2
- package/dist/{requirements.handler-D5dFi7XZ.mjs → requirements.handler-B51sxQSy.mjs} +7 -7
- package/dist/resolve-cli-credentials-DytxgMwn.mjs +47 -0
- package/dist/{resolve-project-CURYMjex.mjs → resolve-project-CNQtOWE4.mjs} +7 -7
- package/dist/{run-polling-BWcLQvm0.mjs → run-polling-CC6y2XXI.mjs} +5 -5
- package/dist/{run.handler-BiBDLoeH.mjs → run.handler-B31BpZJP.mjs} +9 -9
- package/dist/{runs-Bc3zjk7V.mjs → runs-Bg_qDeQi.mjs} +4 -4
- package/dist/{skill-installer-DkRJ6oLi.mjs → skill-installer-BBgN2tzW.mjs} +2 -2
- package/dist/{skills-sync.handler-C4ztv1Vu.mjs → skills-sync.handler-DOxudKmV.mjs} +3 -3
- package/dist/{skills.command-DuL4kLUi.mjs → skills.command-JwKWpGvU.mjs} +5 -5
- package/dist/{skills.handler-R5KAbioE.mjs → skills.handler-Do9I3dQS.mjs} +1 -1
- package/dist/{source-analysis-BBg2E_6G-BQqm16RR.mjs → source-analysis-BBg2E_6G-Ut7kYHOz.mjs} +4 -4
- package/dist/{spinner-progress-DfkMzwGx.mjs → spinner-progress-Bx-fYItP.mjs} +1 -1
- package/dist/{src-BQdOWkyv.mjs → src-B0tNjKMg.mjs} +1 -1
- package/dist/{status.handler-DxCJRm1n.mjs → status.handler-BsVtDW_V.mjs} +19 -4
- package/dist/{switch.handler-CTwhIcaQ.mjs → switch.handler-Cu81T2HY.mjs} +5 -5
- package/dist/{sync-Pssitj6K.mjs → sync-CXNveL61.mjs} +2 -2
- package/dist/{sync.handler-Be0U3x-n.mjs → sync.handler-7g1yDt0H.mjs} +9 -9
- package/dist/{task-BNXDZU71.mjs → task-BguWXIiH.mjs} +2 -2
- package/dist/{task-target-build-BG6cC3bz.mjs → task-target-build-BaMtXnN7.mjs} +8 -7
- package/dist/{task-target-deploy-CZBGNC0H-Ck724yF4.mjs → task-target-deploy-CZBGNC0H-I-tvkGCC.mjs} +1 -1
- package/dist/{task-target-deploy-gMQC8kXU.mjs → task-target-deploy-DmpCWE3u.mjs} +1 -1
- package/dist/task-target-deploy-runner.mjs +22 -17
- package/dist/{test-CKBpp1gg.mjs → test-A5hz3c7j.mjs} +4 -4
- package/dist/{test.handler-DkizZhVu.mjs → test.handler-CJtaMZVy.mjs} +12 -12
- package/dist/{test.handler-Dk3CmTa7.mjs → test.handler-Cq2l7SAr.mjs} +2 -2
- package/dist/{tool.handler--IzRGelu.mjs → tool.handler-B-mOL128.mjs} +34 -14
- package/dist/{trigger-artifacts-RizI57RC-CxHwCkQ_.mjs → trigger-artifacts-RizI57RC-DjhOsdOm.mjs} +4 -4
- package/dist/{trigger-manifest-PTjVYL1r.mjs → trigger-manifest-Bq2zRbkV.mjs} +1 -1
- package/dist/{upgrade-cH9I_pZq.mjs → upgrade-DhfpoyRV.mjs} +2 -2
- package/dist/{upgrade.handler-CXEF4ue0.mjs → upgrade.handler-5qSzPC7D.mjs} +1 -1
- package/dist/{upload.handler-CpKuAaQ_.mjs → upload.handler-D3-W_1kq.mjs} +10 -10
- package/dist/{users.get.handler-D0WO6D1K.mjs → users.get.handler-Bd0OBI-E.mjs} +4 -4
- package/dist/{users.list.handler-BSTIniF1.mjs → users.list.handler-CacJz6eC.mjs} +4 -4
- package/dist/{users.set-role.handler-DAKdSkbn.mjs → users.set-role.handler-bZMQtUR0.mjs} +4 -4
- package/dist/{utils-VC0Vl_pm.mjs → utils-BMUWnz1P.mjs} +2 -2
- package/dist/{validate.handler-I8LY-UkG.mjs → validate.handler-Ccq66ki4.mjs} +8 -8
- package/dist/{workflow-build-C9rQQ4qU.mjs → workflow-build-_WNsLKwW.mjs} +23 -23
- package/dist/{workflow-build-manifest-OPFqFD6f.mjs → workflow-build-manifest-B2GqHyWE.mjs} +3 -3
- package/dist/{workflow-bundler-BzHk73PM-AIB4-u4Y.mjs → workflow-bundler-BzHk73PM-WI31RJjH.mjs} +3 -3
- package/dist/{workflows-CL1jYSLR.mjs → workflows-Dy2M9bEr.mjs} +16 -16
- package/dist/{writer-B-SpZ0G2-olEAgSLc.mjs → writer-B-SpZ0G2-tq1MFgid.mjs} +6 -6
- package/package.json +4 -4
- package/dist/clear.handler-Dpe05eTq.mjs +0 -42
- package/dist/dist-BF6r1hfv.mjs +0 -308
- package/dist/logs-DUwdYZB-.mjs +0 -28
- package/dist/logs.handler-dcRq-zoc.mjs +0 -35
- package/dist/{agent-bundle-package-DWV6B_5q-rRTPU13L.mjs → agent-bundle-package-DWV6B_5q-FPT0bJaA.mjs} +0 -0
- package/dist/{agent-manifest-CZdlCTFs.mjs → agent-manifest-tIsqF2OP.mjs} +0 -0
- package/dist/{browser-3cUiPlk2.mjs → browser-BpJ8ut9z.mjs} +0 -0
- package/dist/{common-BaGFkj3n.mjs → common-AK0q0Oz0.mjs} +0 -0
- package/dist/{concurrency-gXn9Rw8x-BI6HQNfC.mjs → concurrency-gXn9Rw8x-BTlfau8D.mjs} +0 -0
- package/dist/{cron-parser-C2eJD0yD.mjs → cron-parser-Dw_cWzFu.mjs} +0 -0
- package/dist/{declared-credential-requirements-B6h4WRv4.mjs → declared-credential-requirements-D6KT-r-e.mjs} +0 -0
- package/dist/{default-urls-BS4twrsS.mjs → default-urls-CTQqM1_A.mjs} +0 -0
- package/dist/{layout-CXkZEsXI.mjs → layout-P1v-Gssz.mjs} +0 -0
- package/dist/{metadata-layout-Bv-B0nHj-CqlcZz_g.mjs → metadata-layout-Bv-B0nHj-B1c5giJ7.mjs} +1 -1
- package/dist/{oxc-B3KI3rf_-DdiZWqe2.mjs → oxc-B3KI3rf_-Cvx4Z-4H.mjs} +0 -0
- package/dist/{project-config-CsBMT4TL.mjs → project-config-DudGRFPO.mjs} +1 -1
- package/dist/{read-credential-keys-77a91T8M-DMmY6oDW.mjs → read-credential-keys-77a91T8M-B0eiobOd.mjs} +0 -0
- package/dist/{rolldown-runtime-twds-ZHy-CO5ir_za.mjs → rolldown-runtime-twds-ZHy-8uqgIurC.mjs} +0 -0
- package/dist/{run-polling-CwlzB5-9.mjs → run-polling-DARidqo-.mjs} +0 -0
- package/dist/{schema-O9xTWad_.mjs → schema-BjH_e4Fo.mjs} +0 -0
- package/dist/{schema-_FQrHcIS.mjs → schema-Lbp5lGJu.mjs} +0 -0
- package/dist/{schema-display-CNqiYBIb.mjs → schema-display-NVEl_DFY.mjs} +0 -0
- package/dist/{schemas-DodkHgnS.mjs → schemas-B8c7Z5Iy.mjs} +0 -0
- package/dist/{source-analysis-CJPymdaA.mjs → source-analysis-Cs0CTBQk.mjs} +0 -0
- package/dist/{types-D04ah3uY.mjs → types-BMBuhHhW.mjs} +0 -0
- package/dist/{upload-wwSPAC5_.mjs → upload-BbcMkyVl.mjs} +1 -1
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { n as getKeystrokeBaseDir, t as KEYSTROKE_DIR } from "./paths-JzzFkXQA-CEipIeVl.mjs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path$1 from "node:path";
|
|
6
|
+
import * as fs from "node:fs/promises";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
//#region ../../packages/local-memory/dist/index.mjs
|
|
9
|
+
/**
|
|
10
|
+
* Writes `data` to `filePath` atomically.
|
|
11
|
+
*
|
|
12
|
+
* The contents are first written to a sibling temp file in the same directory,
|
|
13
|
+
* fsynced, chmodded, and then renamed onto the target path. On POSIX (and on
|
|
14
|
+
* Windows when the source and destination are on the same volume), `fs.rename`
|
|
15
|
+
* is atomic — readers see either the previous contents or the new contents,
|
|
16
|
+
* never a partial write.
|
|
17
|
+
*
|
|
18
|
+
* The temp filename embeds the process PID and a high-resolution timestamp
|
|
19
|
+
* (`${path}.tmp.${pid}.${ts}`) so concurrent writers do not collide.
|
|
20
|
+
*
|
|
21
|
+
* The parent directory is created with `recursive: true` if it does not already
|
|
22
|
+
* exist, so callers do not need to mkdir beforehand.
|
|
23
|
+
*
|
|
24
|
+
* If anything fails after the temp file is created (write, fsync, rename), the
|
|
25
|
+
* temp file is best-effort unlinked so we don't leak files.
|
|
26
|
+
*/
|
|
27
|
+
async function atomicWriteFile(filePath, data, options = {}) {
|
|
28
|
+
const mode = options.mode ?? 420;
|
|
29
|
+
const dir = path$1.dirname(filePath);
|
|
30
|
+
await fs.mkdir(dir, { recursive: true });
|
|
31
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}`;
|
|
32
|
+
let handleClosed = false;
|
|
33
|
+
const handle = await fs.open(tmpPath, "w", mode);
|
|
34
|
+
try {
|
|
35
|
+
await handle.writeFile(data, "utf-8");
|
|
36
|
+
await handle.sync();
|
|
37
|
+
await handle.close();
|
|
38
|
+
handleClosed = true;
|
|
39
|
+
try {
|
|
40
|
+
await fs.chmod(tmpPath, mode);
|
|
41
|
+
} catch {}
|
|
42
|
+
await fs.rename(tmpPath, filePath);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!handleClosed) try {
|
|
45
|
+
await handle.close();
|
|
46
|
+
} catch {}
|
|
47
|
+
try {
|
|
48
|
+
await fs.unlink(tmpPath);
|
|
49
|
+
} catch {}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Removes `filePath`. Returns `true` if the file existed and was removed,
|
|
55
|
+
* `false` if it did not exist (`ENOENT`). Other errors propagate.
|
|
56
|
+
*/
|
|
57
|
+
async function unlinkFileIfExists(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.unlink(filePath);
|
|
60
|
+
return true;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error.code === "ENOENT") return false;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Reads a UTF-8 file and parses it as JSON.
|
|
68
|
+
*
|
|
69
|
+
* - Returns `null` when the file does not exist (`ENOENT`).
|
|
70
|
+
* - Throws on any other read error.
|
|
71
|
+
* - Throws (with the underlying `SyntaxError`) when the contents are not valid JSON.
|
|
72
|
+
*
|
|
73
|
+
* The return type is `unknown` — callers must validate the shape before using the
|
|
74
|
+
* value (typically via a Zod schema).
|
|
75
|
+
*/
|
|
76
|
+
async function readJsonFile(filePath) {
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = await fs.readFile(filePath, "utf-8");
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code === "ENOENT") return null;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Migrates `raw` to the current schema shape.
|
|
88
|
+
*
|
|
89
|
+
* Strategy:
|
|
90
|
+
* 1. Try the current schema first. If it matches, return immediately.
|
|
91
|
+
* 2. Otherwise find the migration whose `fromSchema` matches `raw`, run its
|
|
92
|
+
* `up`, and re-enter the loop with the result.
|
|
93
|
+
* 3. Continue until the current schema validates the value, or no migration
|
|
94
|
+
* matches — in which case throw.
|
|
95
|
+
*
|
|
96
|
+
* This handles single-hop and chained migrations (V1 → V2 → V3 → ...) without
|
|
97
|
+
* requiring the caller to order migrations carefully.
|
|
98
|
+
*
|
|
99
|
+
* Throws if the value matches no known schema. Callers typically wrap with a
|
|
100
|
+
* descriptive error referencing the file path.
|
|
101
|
+
*/
|
|
102
|
+
function migrateRead(raw, def) {
|
|
103
|
+
const direct = def.schema.safeParse(raw);
|
|
104
|
+
if (direct.success) return {
|
|
105
|
+
value: direct.data,
|
|
106
|
+
migrated: false
|
|
107
|
+
};
|
|
108
|
+
const migrations = def.migrations ?? [];
|
|
109
|
+
if (migrations.length === 0) throw new Error("Stored value does not match the current schema and no migrations are configured.");
|
|
110
|
+
let current = raw;
|
|
111
|
+
const visited = /* @__PURE__ */ new Set();
|
|
112
|
+
for (let hop = 0; hop <= migrations.length; hop++) {
|
|
113
|
+
const match = def.schema.safeParse(current);
|
|
114
|
+
if (match.success) return {
|
|
115
|
+
value: match.data,
|
|
116
|
+
migrated: hop > 0
|
|
117
|
+
};
|
|
118
|
+
const next = migrations.find((m) => !visited.has(m) && m.fromSchema.safeParse(current).success);
|
|
119
|
+
if (!next) throw new Error("Stored value does not match the current schema or any known migration source.");
|
|
120
|
+
visited.add(next);
|
|
121
|
+
const validated = next.fromSchema.parse(current);
|
|
122
|
+
current = next.up(validated);
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`Migration chain did not converge after ${migrations.length} hops. Possible cycle in migrations.`);
|
|
125
|
+
}
|
|
126
|
+
const KEYRING_PACKAGE = ["@napi-rs", "keyring"].join("/");
|
|
127
|
+
async function createDefaultEntry(service, account) {
|
|
128
|
+
const { Entry } = await import(KEYRING_PACKAGE);
|
|
129
|
+
return new Entry(service, account);
|
|
130
|
+
}
|
|
131
|
+
var KeychainVault = class {
|
|
132
|
+
kind = "keychain";
|
|
133
|
+
service;
|
|
134
|
+
createEntry;
|
|
135
|
+
constructor(options) {
|
|
136
|
+
this.service = options.service;
|
|
137
|
+
this.createEntry = options.createEntry ?? createDefaultEntry;
|
|
138
|
+
}
|
|
139
|
+
async get(account) {
|
|
140
|
+
return (await this.createEntry(this.service, account)).getPassword();
|
|
141
|
+
}
|
|
142
|
+
async set(account, secret) {
|
|
143
|
+
(await this.createEntry(this.service, account)).setPassword(secret);
|
|
144
|
+
}
|
|
145
|
+
async delete(account) {
|
|
146
|
+
return (await this.createEntry(this.service, account)).deleteCredential();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const FORBIDDEN_NAME_PATTERNS = [
|
|
150
|
+
{
|
|
151
|
+
test: (n) => n.length === 0,
|
|
152
|
+
message: "must not be empty"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
test: (n) => n.includes("\\"),
|
|
156
|
+
message: "must not contain backslashes"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
test: (n) => n.split("/").some((seg) => seg === ".."),
|
|
160
|
+
message: "must not contain \"..\" segments"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
test: (n) => n.startsWith("/"),
|
|
164
|
+
message: "must be relative (cannot start with \"/\")"
|
|
165
|
+
}
|
|
166
|
+
];
|
|
167
|
+
function validateStoreName(name) {
|
|
168
|
+
for (const { test, message } of FORBIDDEN_NAME_PATTERNS) if (test(name)) throw new Error(`Invalid Store name ${JSON.stringify(name)}: ${message}.`);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Typed, schema-validated, atomically-written abstraction over a single JSON
|
|
172
|
+
* file under `~/.keystroke/`.
|
|
173
|
+
*
|
|
174
|
+
* `Store` is an internal primitive of `@keystroke/local-memory`. It is not
|
|
175
|
+
* exported from the package's public API — domain controllers (`Credentials`,
|
|
176
|
+
* `Projects`, etc.) construct stores internally and expose domain-shaped methods
|
|
177
|
+
* to consumers.
|
|
178
|
+
*/
|
|
179
|
+
var Store = class {
|
|
180
|
+
/** Absolute path to the file this store manages. */
|
|
181
|
+
filePath;
|
|
182
|
+
options;
|
|
183
|
+
constructor(options) {
|
|
184
|
+
validateStoreName(options.name);
|
|
185
|
+
this.options = options;
|
|
186
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
187
|
+
this.filePath = path$1.join(getKeystrokeBaseDir(homeDir), ...options.name.split("/"));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Returns the parsed contents of the file, or `null` when the file does not
|
|
191
|
+
* exist. If the on-disk shape does not match the current schema, registered
|
|
192
|
+
* migrations are applied and the upgraded value is persisted back.
|
|
193
|
+
*
|
|
194
|
+
* Throws when the file exists but matches no known schema, or when the file
|
|
195
|
+
* is unreadable for reasons other than ENOENT.
|
|
196
|
+
*/
|
|
197
|
+
async read() {
|
|
198
|
+
const raw = await readJsonFile(this.filePath);
|
|
199
|
+
if (raw === null) return null;
|
|
200
|
+
let result;
|
|
201
|
+
try {
|
|
202
|
+
result = migrateRead(raw, {
|
|
203
|
+
schema: this.options.schema,
|
|
204
|
+
migrations: this.options.migrations
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
+
throw new Error(`Invalid file at ${this.filePath}: ${message}`);
|
|
209
|
+
}
|
|
210
|
+
if (result.migrated) await this.write(result.value);
|
|
211
|
+
return result.value;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Replaces the file with `value`. Validates against the schema first (throws
|
|
215
|
+
* synchronously on invalid input, before any I/O). The write is atomic:
|
|
216
|
+
* write-temp + rename, never visible mid-write.
|
|
217
|
+
*/
|
|
218
|
+
async write(value) {
|
|
219
|
+
const validated = this.options.schema.parse(value);
|
|
220
|
+
const json = `${JSON.stringify(validated, null, 2)}\n`;
|
|
221
|
+
await atomicWriteFile(this.filePath, json, { mode: this.options.fileMode });
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Read-modify-write. Reads the current value (or `defaults` if the file does
|
|
225
|
+
* not exist), passes it to `fn`, validates the result, and writes it back
|
|
226
|
+
* atomically. Returns the new value.
|
|
227
|
+
*
|
|
228
|
+
* Throws if the file does not exist and no `defaults` were configured.
|
|
229
|
+
*/
|
|
230
|
+
async update(fn) {
|
|
231
|
+
const current = await this.read();
|
|
232
|
+
let base;
|
|
233
|
+
if (current !== null) base = current;
|
|
234
|
+
else if (this.options.defaults !== void 0) base = this.options.defaults;
|
|
235
|
+
else throw new Error(`Cannot update ${this.filePath}: file does not exist and no defaults are configured.`);
|
|
236
|
+
const next = fn(base);
|
|
237
|
+
await this.write(next);
|
|
238
|
+
return next;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Returns the value at `key`, or `undefined` if the file does not exist.
|
|
242
|
+
* Each call re-reads the file (no caching).
|
|
243
|
+
*/
|
|
244
|
+
async get(key) {
|
|
245
|
+
const value = await this.read();
|
|
246
|
+
if (value === null) return void 0;
|
|
247
|
+
return value[key];
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Sets the value at `key`. Other keys are preserved. Uses `defaults` if the
|
|
251
|
+
* file does not exist (throws if missing and no defaults).
|
|
252
|
+
*/
|
|
253
|
+
async set(key, value) {
|
|
254
|
+
await this.update((current) => ({
|
|
255
|
+
...current,
|
|
256
|
+
[key]: value
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Returns true if the file exists and the value at `key` is not `undefined`.
|
|
261
|
+
*/
|
|
262
|
+
async has(key) {
|
|
263
|
+
return await this.get(key) !== void 0;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Removes the file. Returns `true` if the file existed, `false` if it did
|
|
267
|
+
* not.
|
|
268
|
+
*/
|
|
269
|
+
async delete() {
|
|
270
|
+
return unlinkFileIfExists(this.filePath);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const CREDENTIAL_SECRET_STORAGE_UNAVAILABLE = "CREDENTIAL_SECRET_STORAGE_UNAVAILABLE";
|
|
274
|
+
const CREDENTIAL_SECRET_READ_FAILED = "CREDENTIAL_SECRET_READ_FAILED";
|
|
275
|
+
const CREDENTIAL_SECRET_WRITE_FAILED = "CREDENTIAL_SECRET_WRITE_FAILED";
|
|
276
|
+
const CREDENTIAL_SECRET_DELETE_FAILED = "CREDENTIAL_SECRET_DELETE_FAILED";
|
|
277
|
+
var CredentialSecretError = class extends Error {
|
|
278
|
+
constructor(message, options) {
|
|
279
|
+
super(message, options);
|
|
280
|
+
this.name = new.target.name;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
var CredentialSecretStorageUnavailableError = class extends CredentialSecretError {
|
|
284
|
+
code = CREDENTIAL_SECRET_STORAGE_UNAVAILABLE;
|
|
285
|
+
};
|
|
286
|
+
var CredentialSecretReadError = class extends CredentialSecretError {
|
|
287
|
+
code = CREDENTIAL_SECRET_READ_FAILED;
|
|
288
|
+
};
|
|
289
|
+
var CredentialSecretWriteError = class extends CredentialSecretError {
|
|
290
|
+
code = CREDENTIAL_SECRET_WRITE_FAILED;
|
|
291
|
+
};
|
|
292
|
+
var CredentialSecretDeleteError = class extends CredentialSecretError {
|
|
293
|
+
code = CREDENTIAL_SECRET_DELETE_FAILED;
|
|
294
|
+
};
|
|
295
|
+
const credentialUserSchema = z.object({
|
|
296
|
+
id: z.string().min(1),
|
|
297
|
+
email: z.email(),
|
|
298
|
+
name: z.string().optional()
|
|
299
|
+
});
|
|
300
|
+
/**
|
|
301
|
+
* Metadata about an organization the user is authenticated against.
|
|
302
|
+
* Note: `apiKey` is NOT in this shape — it lives in the secrets file.
|
|
303
|
+
*
|
|
304
|
+
* Strict on purpose: extra fields (like the legacy `apiKey`) cause the schema
|
|
305
|
+
* to fail, which routes the read through the migration chain rather than
|
|
306
|
+
* silently stripping the unknown field at parse time.
|
|
307
|
+
*/
|
|
308
|
+
const orgEntrySchema = z.object({
|
|
309
|
+
organizationId: z.uuid(),
|
|
310
|
+
organizationName: z.string().min(1),
|
|
311
|
+
apiKeyId: z.uuid().optional(),
|
|
312
|
+
createdAt: z.string().min(1)
|
|
313
|
+
}).strict();
|
|
314
|
+
const credentialSecretStorageSchema = z.discriminatedUnion("kind", [z.object({
|
|
315
|
+
kind: z.literal("keychain"),
|
|
316
|
+
service: z.string().min(1)
|
|
317
|
+
}).strict(), z.object({
|
|
318
|
+
kind: z.literal("file"),
|
|
319
|
+
reason: z.enum([
|
|
320
|
+
"legacy",
|
|
321
|
+
"insecure-storage",
|
|
322
|
+
"test"
|
|
323
|
+
]).optional()
|
|
324
|
+
}).strict()]);
|
|
325
|
+
const credentialsMetadataSchema = z.object({
|
|
326
|
+
version: z.literal(3),
|
|
327
|
+
serverUrl: z.url(),
|
|
328
|
+
webUrl: z.url(),
|
|
329
|
+
user: credentialUserSchema.optional(),
|
|
330
|
+
activeOrgId: z.uuid().optional(),
|
|
331
|
+
orgs: z.array(orgEntrySchema),
|
|
332
|
+
secretStorage: credentialSecretStorageSchema
|
|
333
|
+
}).strict();
|
|
334
|
+
const orgSecretsSchema = z.object({
|
|
335
|
+
version: z.literal(1),
|
|
336
|
+
/** Map of organizationId → API key. Empty when no orgs configured. */
|
|
337
|
+
byOrgId: z.record(z.uuid(), z.string().min(1))
|
|
338
|
+
});
|
|
339
|
+
const credentialsMetadataSchemaV2 = z.object({
|
|
340
|
+
version: z.literal(2),
|
|
341
|
+
serverUrl: z.url(),
|
|
342
|
+
webUrl: z.url(),
|
|
343
|
+
user: credentialUserSchema.optional(),
|
|
344
|
+
activeOrgId: z.uuid().optional(),
|
|
345
|
+
orgs: z.array(orgEntrySchema)
|
|
346
|
+
}).strict();
|
|
347
|
+
/**
|
|
348
|
+
* Old V2 shape: same as current metadata, but each org carried its own
|
|
349
|
+
* `apiKey` field. Migration: strip `apiKey` from each org. The actual key
|
|
350
|
+
* bytes are recovered separately by the controller's legacy import step.
|
|
351
|
+
*
|
|
352
|
+
* `oldV2OrgEntrySchema` extends the current strict org schema with a required
|
|
353
|
+
* `apiKey`. Strict so a V2-new entry (no apiKey) does NOT match this schema.
|
|
354
|
+
*/
|
|
355
|
+
const oldV2OrgEntrySchema = orgEntrySchema.extend({ apiKey: z.string().min(1) }).strict();
|
|
356
|
+
const credentialsSchemaV2OldShape = z.object({
|
|
357
|
+
version: z.literal(2),
|
|
358
|
+
serverUrl: z.url(),
|
|
359
|
+
webUrl: z.url(),
|
|
360
|
+
user: credentialUserSchema.optional(),
|
|
361
|
+
activeOrgId: z.uuid().optional(),
|
|
362
|
+
orgs: z.array(oldV2OrgEntrySchema)
|
|
363
|
+
}).strict();
|
|
364
|
+
/**
|
|
365
|
+
* V1 shape: a single embedded apiKey + organization. Migration: collapse to a
|
|
366
|
+
* one-org metadata record. The apiKey bytes are recovered separately.
|
|
367
|
+
*/
|
|
368
|
+
const credentialsSchemaV1 = z.object({
|
|
369
|
+
version: z.literal(1),
|
|
370
|
+
apiKey: z.string().min(1),
|
|
371
|
+
apiKeyId: z.uuid().optional(),
|
|
372
|
+
serverUrl: z.url(),
|
|
373
|
+
webUrl: z.url(),
|
|
374
|
+
createdAt: z.string().min(1),
|
|
375
|
+
organizationId: z.uuid().optional(),
|
|
376
|
+
organizationName: z.string().optional(),
|
|
377
|
+
user: credentialUserSchema.optional()
|
|
378
|
+
});
|
|
379
|
+
const SECRETS_FILE = "secrets.json";
|
|
380
|
+
const SECRETS_VERSION = 1;
|
|
381
|
+
const SECRETS_DEFAULTS = {
|
|
382
|
+
version: SECRETS_VERSION,
|
|
383
|
+
byOrgId: {}
|
|
384
|
+
};
|
|
385
|
+
const CREDENTIAL_KEYCHAIN_SERVICE = "io.keystroke.cli";
|
|
386
|
+
var CredentialSecrets = class {
|
|
387
|
+
legacySecrets;
|
|
388
|
+
vault;
|
|
389
|
+
constructor(options = {}) {
|
|
390
|
+
this.vault = options.vault;
|
|
391
|
+
this.legacySecrets = new Store({
|
|
392
|
+
name: SECRETS_FILE,
|
|
393
|
+
version: SECRETS_VERSION,
|
|
394
|
+
schema: orgSecretsSchema,
|
|
395
|
+
defaults: SECRETS_DEFAULTS,
|
|
396
|
+
fileMode: 384,
|
|
397
|
+
homeDir: options.homeDir
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
get legacySecretsFilePath() {
|
|
401
|
+
return this.legacySecrets.filePath;
|
|
402
|
+
}
|
|
403
|
+
async getApiKey(ref, storageKind) {
|
|
404
|
+
if (storageKind === "file") return (await this.legacySecrets.read())?.byOrgId[ref.orgId] ?? null;
|
|
405
|
+
const vault = this.requireVault();
|
|
406
|
+
try {
|
|
407
|
+
return await vault.get(getCredentialSecretAccountName(ref));
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw new CredentialSecretReadError("Could not read the Keystroke API key from the credential store.", { cause: error });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async setApiKey(ref, apiKey, storageKind) {
|
|
413
|
+
if (storageKind === "file") {
|
|
414
|
+
await this.legacySecrets.update((secrets) => ({
|
|
415
|
+
...secrets,
|
|
416
|
+
byOrgId: {
|
|
417
|
+
...secrets.byOrgId,
|
|
418
|
+
[ref.orgId]: apiKey
|
|
419
|
+
}
|
|
420
|
+
}));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const vault = this.requireVault();
|
|
424
|
+
try {
|
|
425
|
+
await vault.set(getCredentialSecretAccountName(ref), apiKey);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
throw new CredentialSecretWriteError("Could not save the Keystroke API key to the credential store.", { cause: error });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async deleteApiKey(ref, storageKind) {
|
|
431
|
+
if (storageKind === "file") {
|
|
432
|
+
const current = await this.legacySecrets.read();
|
|
433
|
+
if (!current || current.byOrgId[ref.orgId] === void 0) return false;
|
|
434
|
+
await this.legacySecrets.update((secrets) => {
|
|
435
|
+
const { [ref.orgId]: _removed, ...rest } = secrets.byOrgId;
|
|
436
|
+
return {
|
|
437
|
+
...secrets,
|
|
438
|
+
byOrgId: rest
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
const vault = this.requireVault();
|
|
444
|
+
try {
|
|
445
|
+
return await vault.delete(getCredentialSecretAccountName(ref));
|
|
446
|
+
} catch (error) {
|
|
447
|
+
throw new CredentialSecretDeleteError("Could not delete the Keystroke API key from the credential store.", { cause: error });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async clearLegacySecrets() {
|
|
451
|
+
return this.legacySecrets.delete();
|
|
452
|
+
}
|
|
453
|
+
async writeLegacySecrets(secrets) {
|
|
454
|
+
await this.legacySecrets.write(secrets);
|
|
455
|
+
}
|
|
456
|
+
async readLegacySecrets() {
|
|
457
|
+
return this.legacySecrets.read();
|
|
458
|
+
}
|
|
459
|
+
requireVault() {
|
|
460
|
+
if (!this.vault) throw new CredentialSecretStorageUnavailableError("Credential store is not configured for secure secret storage.");
|
|
461
|
+
return this.vault;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
function getCredentialSecretAccountName(ref) {
|
|
465
|
+
return `api-key:${normalizeCredentialServerUrl(ref.serverUrl)}:${ref.orgId}`;
|
|
466
|
+
}
|
|
467
|
+
function normalizeCredentialServerUrl(rawUrl) {
|
|
468
|
+
const parsed = new URL(rawUrl);
|
|
469
|
+
parsed.protocol = parsed.protocol.toLowerCase();
|
|
470
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
471
|
+
const normalized = parsed.toString();
|
|
472
|
+
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
473
|
+
}
|
|
474
|
+
const CREDENTIALS_FILE = "credentials.json";
|
|
475
|
+
const METADATA_VERSION = 3;
|
|
476
|
+
const KEYCHAIN_STORAGE = {
|
|
477
|
+
kind: "keychain",
|
|
478
|
+
service: CREDENTIAL_KEYCHAIN_SERVICE
|
|
479
|
+
};
|
|
480
|
+
const TEST_FILE_STORAGE = {
|
|
481
|
+
kind: "file",
|
|
482
|
+
reason: "test"
|
|
483
|
+
};
|
|
484
|
+
const LEGACY_FILE_STORAGE = {
|
|
485
|
+
kind: "file",
|
|
486
|
+
reason: "legacy"
|
|
487
|
+
};
|
|
488
|
+
const INSECURE_FILE_STORAGE = {
|
|
489
|
+
kind: "file",
|
|
490
|
+
reason: "insecure-storage"
|
|
491
|
+
};
|
|
492
|
+
function createKeychainVault() {
|
|
493
|
+
return new KeychainVault({ service: CREDENTIAL_KEYCHAIN_SERVICE });
|
|
494
|
+
}
|
|
495
|
+
function resolveDefaultSecretStorage(options) {
|
|
496
|
+
if (options.secretStorage === "file") return TEST_FILE_STORAGE;
|
|
497
|
+
if (options.secretStorage === "keychain") return KEYCHAIN_STORAGE;
|
|
498
|
+
if (options.vault) return KEYCHAIN_STORAGE;
|
|
499
|
+
return options.homeDir ? TEST_FILE_STORAGE : KEYCHAIN_STORAGE;
|
|
500
|
+
}
|
|
501
|
+
function toSecretStorage(preference, fallback) {
|
|
502
|
+
if (preference === "file") return INSECURE_FILE_STORAGE;
|
|
503
|
+
if (preference === "keychain") return KEYCHAIN_STORAGE;
|
|
504
|
+
return fallback;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Domain controller for Keystroke credentials.
|
|
508
|
+
*
|
|
509
|
+
* Currently backed by two files under `~/.keystroke/`:
|
|
510
|
+
* - `credentials.json` — metadata (org list, server URLs, active org pointer)
|
|
511
|
+
* - `secrets.json` — `{ version, byOrgId: { [orgId]: apiKey } }`
|
|
512
|
+
*
|
|
513
|
+
* Secret reads and writes go through `CredentialSecrets`, keeping the storage
|
|
514
|
+
* mechanism behind this controller as PR 9 moves API keys to the OS keychain.
|
|
515
|
+
*
|
|
516
|
+
* Production code uses the `credentials` singleton exported below. Tests
|
|
517
|
+
* construct a fresh instance with `homeDir` pointing at a tempdir.
|
|
518
|
+
*/
|
|
519
|
+
var Credentials = class {
|
|
520
|
+
metadata;
|
|
521
|
+
secrets;
|
|
522
|
+
defaultSecretStorage;
|
|
523
|
+
legacyImportComplete = false;
|
|
524
|
+
constructor(options = {}) {
|
|
525
|
+
const defaultSecretStorage = resolveDefaultSecretStorage(options);
|
|
526
|
+
this.defaultSecretStorage = defaultSecretStorage;
|
|
527
|
+
this.metadata = new Store({
|
|
528
|
+
name: CREDENTIALS_FILE,
|
|
529
|
+
version: METADATA_VERSION,
|
|
530
|
+
schema: credentialsMetadataSchema,
|
|
531
|
+
fileMode: 384,
|
|
532
|
+
homeDir: options.homeDir,
|
|
533
|
+
migrations: [
|
|
534
|
+
{
|
|
535
|
+
fromVersion: 2,
|
|
536
|
+
fromSchema: credentialsMetadataSchemaV2,
|
|
537
|
+
up: (raw) => {
|
|
538
|
+
const v2 = raw;
|
|
539
|
+
return {
|
|
540
|
+
version: METADATA_VERSION,
|
|
541
|
+
serverUrl: v2.serverUrl,
|
|
542
|
+
webUrl: v2.webUrl,
|
|
543
|
+
user: v2.user,
|
|
544
|
+
activeOrgId: v2.activeOrgId,
|
|
545
|
+
orgs: v2.orgs,
|
|
546
|
+
secretStorage: LEGACY_FILE_STORAGE
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
fromVersion: 2,
|
|
552
|
+
fromSchema: credentialsSchemaV2OldShape,
|
|
553
|
+
up: (raw) => {
|
|
554
|
+
const old = raw;
|
|
555
|
+
return {
|
|
556
|
+
version: METADATA_VERSION,
|
|
557
|
+
serverUrl: old.serverUrl,
|
|
558
|
+
webUrl: old.webUrl,
|
|
559
|
+
user: old.user,
|
|
560
|
+
activeOrgId: old.activeOrgId,
|
|
561
|
+
orgs: old.orgs.map(({ apiKey: _apiKey, ...rest }) => rest),
|
|
562
|
+
secretStorage: LEGACY_FILE_STORAGE
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
fromVersion: 1,
|
|
568
|
+
fromSchema: credentialsSchemaV1,
|
|
569
|
+
up: (raw) => {
|
|
570
|
+
const v1 = raw;
|
|
571
|
+
return {
|
|
572
|
+
version: METADATA_VERSION,
|
|
573
|
+
serverUrl: v1.serverUrl,
|
|
574
|
+
webUrl: v1.webUrl,
|
|
575
|
+
user: v1.user,
|
|
576
|
+
activeOrgId: v1.organizationId,
|
|
577
|
+
orgs: v1.organizationId ? [{
|
|
578
|
+
organizationId: v1.organizationId,
|
|
579
|
+
organizationName: v1.organizationName ?? "Unknown",
|
|
580
|
+
apiKeyId: v1.apiKeyId,
|
|
581
|
+
createdAt: v1.createdAt
|
|
582
|
+
}] : [],
|
|
583
|
+
secretStorage: LEGACY_FILE_STORAGE
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
]
|
|
588
|
+
});
|
|
589
|
+
this.secrets = new CredentialSecrets({
|
|
590
|
+
homeDir: options.homeDir,
|
|
591
|
+
vault: options.vault ?? (defaultSecretStorage.kind === "keychain" ? createKeychainVault() : void 0)
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
/** Absolute path to the credentials metadata file. */
|
|
595
|
+
get metadataFilePath() {
|
|
596
|
+
return this.metadata.filePath;
|
|
597
|
+
}
|
|
598
|
+
/** Absolute path to the secrets file. */
|
|
599
|
+
get secretsFilePath() {
|
|
600
|
+
return this.secrets.legacySecretsFilePath;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Returns the active org and its API key, or `null` when there are no
|
|
604
|
+
* stored credentials, no active org, or no key found for the active org.
|
|
605
|
+
*/
|
|
606
|
+
async getActiveOrg() {
|
|
607
|
+
await this.importLegacyIfNeeded();
|
|
608
|
+
const meta = await this.metadata.read();
|
|
609
|
+
if (!meta?.activeOrgId) return null;
|
|
610
|
+
const org = meta.orgs.find((o) => o.organizationId === meta.activeOrgId);
|
|
611
|
+
if (!org) return null;
|
|
612
|
+
const apiKey = await this.secrets.getApiKey({
|
|
613
|
+
orgId: meta.activeOrgId,
|
|
614
|
+
serverUrl: meta.serverUrl
|
|
615
|
+
}, meta.secretStorage.kind);
|
|
616
|
+
if (!apiKey) return null;
|
|
617
|
+
return {
|
|
618
|
+
org,
|
|
619
|
+
apiKey
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
/** All known orgs (without secrets). Empty when no credentials stored. */
|
|
623
|
+
async listOrgs() {
|
|
624
|
+
await this.importLegacyIfNeeded();
|
|
625
|
+
return (await this.metadata.read())?.orgs ?? [];
|
|
626
|
+
}
|
|
627
|
+
/** API key for a specific org, or `null` if not stored. */
|
|
628
|
+
async getApiKey(orgId) {
|
|
629
|
+
await this.importLegacyIfNeeded();
|
|
630
|
+
const meta = await this.metadata.read();
|
|
631
|
+
if (!meta) return null;
|
|
632
|
+
return this.secrets.getApiKey({
|
|
633
|
+
orgId,
|
|
634
|
+
serverUrl: meta.serverUrl
|
|
635
|
+
}, meta.secretStorage.kind);
|
|
636
|
+
}
|
|
637
|
+
/** Server URLs (shared across all orgs in the file). `null` when unset. */
|
|
638
|
+
async getServerUrls() {
|
|
639
|
+
await this.importLegacyIfNeeded();
|
|
640
|
+
const meta = await this.metadata.read();
|
|
641
|
+
return meta ? {
|
|
642
|
+
serverUrl: meta.serverUrl,
|
|
643
|
+
webUrl: meta.webUrl
|
|
644
|
+
} : null;
|
|
645
|
+
}
|
|
646
|
+
/** The user identity associated with stored credentials, if any. */
|
|
647
|
+
async getUser() {
|
|
648
|
+
await this.importLegacyIfNeeded();
|
|
649
|
+
return (await this.metadata.read())?.user;
|
|
650
|
+
}
|
|
651
|
+
/** Active organization ID, or `undefined` when none is set. */
|
|
652
|
+
async getActiveOrgId() {
|
|
653
|
+
await this.importLegacyIfNeeded();
|
|
654
|
+
return (await this.metadata.read())?.activeOrgId;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Returns `true` when credential metadata contains at least one org. Callers
|
|
658
|
+
* that need the actual API key should use `getActiveOrg()` or `getApiKey()`.
|
|
659
|
+
*/
|
|
660
|
+
async hasStoredCredentials() {
|
|
661
|
+
await this.importLegacyIfNeeded();
|
|
662
|
+
const meta = await this.metadata.read();
|
|
663
|
+
return meta !== null && meta.orgs.length > 0;
|
|
664
|
+
}
|
|
665
|
+
async getStorageInfo() {
|
|
666
|
+
await this.importLegacyIfNeeded();
|
|
667
|
+
const storage = (await this.metadata.read())?.secretStorage ?? this.defaultSecretStorage;
|
|
668
|
+
return {
|
|
669
|
+
metadataFilePath: this.metadata.filePath,
|
|
670
|
+
legacySecretsFilePath: this.secrets.legacySecretsFilePath,
|
|
671
|
+
secretStorageKind: storage.kind,
|
|
672
|
+
...storage.kind === "keychain" ? { keychainService: storage.service } : {},
|
|
673
|
+
insecureFileStorage: storage.kind === "file"
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Adds or replaces an org entry. Sets it as the active org. Creates both
|
|
678
|
+
* files if neither exists. Atomic per-file (each file is a Store write).
|
|
679
|
+
*/
|
|
680
|
+
async upsertOrg(input) {
|
|
681
|
+
await this.importLegacyIfNeeded();
|
|
682
|
+
const existing = await this.metadata.read();
|
|
683
|
+
const secretStorage = toSecretStorage(input.secretStorage, this.defaultSecretStorage);
|
|
684
|
+
if (existing?.secretStorage.kind === "file" && secretStorage.kind === "keychain") await this.migrateFileSecretsToKeychain(existing);
|
|
685
|
+
const newMeta = existing ? {
|
|
686
|
+
...existing,
|
|
687
|
+
serverUrl: input.serverUrl,
|
|
688
|
+
webUrl: input.webUrl,
|
|
689
|
+
user: input.user ?? existing.user,
|
|
690
|
+
activeOrgId: input.org.organizationId,
|
|
691
|
+
secretStorage,
|
|
692
|
+
orgs: [...existing.orgs.filter((o) => o.organizationId !== input.org.organizationId), input.org]
|
|
693
|
+
} : {
|
|
694
|
+
version: METADATA_VERSION,
|
|
695
|
+
serverUrl: input.serverUrl,
|
|
696
|
+
webUrl: input.webUrl,
|
|
697
|
+
user: input.user,
|
|
698
|
+
activeOrgId: input.org.organizationId,
|
|
699
|
+
secretStorage,
|
|
700
|
+
orgs: [input.org]
|
|
701
|
+
};
|
|
702
|
+
await this.metadata.write(newMeta);
|
|
703
|
+
await this.secrets.setApiKey({
|
|
704
|
+
orgId: input.org.organizationId,
|
|
705
|
+
serverUrl: input.serverUrl
|
|
706
|
+
}, input.apiKey, secretStorage.kind);
|
|
707
|
+
if (existing) {
|
|
708
|
+
const previousOrg = existing.orgs.find((org) => org.organizationId === input.org.organizationId);
|
|
709
|
+
const serverUrlChanged = normalizeCredentialServerUrl(existing.serverUrl) !== normalizeCredentialServerUrl(input.serverUrl);
|
|
710
|
+
if (previousOrg && serverUrlChanged && (existing.secretStorage.kind === "keychain" || secretStorage.kind === "keychain")) await this.secrets.deleteApiKey({
|
|
711
|
+
orgId: input.org.organizationId,
|
|
712
|
+
serverUrl: existing.serverUrl
|
|
713
|
+
}, "keychain");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Switches the active org. Throws when no credentials exist or the org is
|
|
718
|
+
* not in the stored orgs array.
|
|
719
|
+
*/
|
|
720
|
+
async setActiveOrg(orgId) {
|
|
721
|
+
await this.importLegacyIfNeeded();
|
|
722
|
+
const meta = await this.metadata.read();
|
|
723
|
+
if (!meta) throw new Error("No stored credentials found. Run `keystroke auth` first.");
|
|
724
|
+
if (!meta.orgs.some((o) => o.organizationId === orgId)) throw new Error(`No stored API key for organization ${orgId}. Run \`keystroke auth\` to add credentials for this org.`);
|
|
725
|
+
await this.metadata.update((m) => ({
|
|
726
|
+
...m,
|
|
727
|
+
activeOrgId: orgId
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Removes a single org. If it was the active org, switches to the first
|
|
732
|
+
* remaining org. When the last org is removed, both files are deleted.
|
|
733
|
+
* Returns the removed org, or `null` if not stored.
|
|
734
|
+
*/
|
|
735
|
+
async removeOrg(orgId) {
|
|
736
|
+
await this.importLegacyIfNeeded();
|
|
737
|
+
const meta = await this.metadata.read();
|
|
738
|
+
if (!meta) return null;
|
|
739
|
+
const removed = meta.orgs.find((o) => o.organizationId === orgId);
|
|
740
|
+
if (!removed) return null;
|
|
741
|
+
const remaining = meta.orgs.filter((o) => o.organizationId !== orgId);
|
|
742
|
+
await this.secrets.deleteApiKey({
|
|
743
|
+
orgId,
|
|
744
|
+
serverUrl: meta.serverUrl
|
|
745
|
+
}, meta.secretStorage.kind);
|
|
746
|
+
if (remaining.length === 0) {
|
|
747
|
+
if (meta.secretStorage.kind === "file") await this.secrets.clearLegacySecrets();
|
|
748
|
+
await this.metadata.delete();
|
|
749
|
+
return removed;
|
|
750
|
+
}
|
|
751
|
+
const newActiveId = meta.activeOrgId === orgId ? remaining[0]?.organizationId : meta.activeOrgId;
|
|
752
|
+
await this.metadata.write({
|
|
753
|
+
...meta,
|
|
754
|
+
orgs: remaining,
|
|
755
|
+
activeOrgId: newActiveId
|
|
756
|
+
});
|
|
757
|
+
return removed;
|
|
758
|
+
}
|
|
759
|
+
/** Wipes both files. After this, `getActiveOrg` returns `null`. */
|
|
760
|
+
async clear() {
|
|
761
|
+
await this.importLegacyIfNeeded();
|
|
762
|
+
const meta = await this.metadata.read();
|
|
763
|
+
if (meta) for (const org of meta.orgs) await this.secrets.deleteApiKey({
|
|
764
|
+
orgId: org.organizationId,
|
|
765
|
+
serverUrl: meta.serverUrl
|
|
766
|
+
}, meta.secretStorage.kind);
|
|
767
|
+
await this.secrets.clearLegacySecrets();
|
|
768
|
+
await this.metadata.delete();
|
|
769
|
+
this.legacyImportComplete = false;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Detects pre-split shapes on disk (V1, or V2-old with embedded apiKeys) and
|
|
773
|
+
* writes the secrets file out before the Store-level migration strips the
|
|
774
|
+
* apiKeys from metadata. Idempotent: caches a per-instance flag so repeated
|
|
775
|
+
* calls after the first cost only the flag check.
|
|
776
|
+
*
|
|
777
|
+
* Implementation note: this runs BEFORE every `metadata.read()` in the
|
|
778
|
+
* controller's API methods. The first call detects-and-imports; subsequent
|
|
779
|
+
* calls are no-ops. Once import is done (or skipped because the file already
|
|
780
|
+
* matches the new shape), `metadata.read()` runs the V2-old/V1 migration
|
|
781
|
+
* which is purely a metadata reshape.
|
|
782
|
+
*/
|
|
783
|
+
async importLegacyIfNeeded() {
|
|
784
|
+
if (this.legacyImportComplete) return;
|
|
785
|
+
this.legacyImportComplete = true;
|
|
786
|
+
let raw;
|
|
787
|
+
try {
|
|
788
|
+
raw = await readJsonFile(this.metadata.filePath);
|
|
789
|
+
} catch {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (raw === null) return;
|
|
793
|
+
const v2Old = credentialsSchemaV2OldShape.safeParse(raw);
|
|
794
|
+
if (v2Old.success) {
|
|
795
|
+
const byOrgId = {};
|
|
796
|
+
for (const org of v2Old.data.orgs) byOrgId[org.organizationId] = org.apiKey;
|
|
797
|
+
const secretStorage = await this.importLegacySecrets({
|
|
798
|
+
metadata: {
|
|
799
|
+
version: METADATA_VERSION,
|
|
800
|
+
serverUrl: v2Old.data.serverUrl,
|
|
801
|
+
webUrl: v2Old.data.webUrl,
|
|
802
|
+
user: v2Old.data.user,
|
|
803
|
+
activeOrgId: v2Old.data.activeOrgId,
|
|
804
|
+
orgs: v2Old.data.orgs.map(({ apiKey: _apiKey, ...rest }) => rest),
|
|
805
|
+
secretStorage: this.defaultSecretStorage
|
|
806
|
+
},
|
|
807
|
+
byOrgId
|
|
808
|
+
});
|
|
809
|
+
await this.metadata.write({
|
|
810
|
+
version: METADATA_VERSION,
|
|
811
|
+
serverUrl: v2Old.data.serverUrl,
|
|
812
|
+
webUrl: v2Old.data.webUrl,
|
|
813
|
+
user: v2Old.data.user,
|
|
814
|
+
activeOrgId: v2Old.data.activeOrgId,
|
|
815
|
+
orgs: v2Old.data.orgs.map(({ apiKey: _apiKey, ...rest }) => rest),
|
|
816
|
+
secretStorage
|
|
817
|
+
});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const v2 = credentialsMetadataSchemaV2.safeParse(raw);
|
|
821
|
+
if (v2.success) {
|
|
822
|
+
const legacySecrets = await this.secrets.readLegacySecrets();
|
|
823
|
+
const secretStorage = await this.importLegacySecrets({
|
|
824
|
+
metadata: {
|
|
825
|
+
version: METADATA_VERSION,
|
|
826
|
+
serverUrl: v2.data.serverUrl,
|
|
827
|
+
webUrl: v2.data.webUrl,
|
|
828
|
+
user: v2.data.user,
|
|
829
|
+
activeOrgId: v2.data.activeOrgId,
|
|
830
|
+
orgs: v2.data.orgs,
|
|
831
|
+
secretStorage: this.defaultSecretStorage
|
|
832
|
+
},
|
|
833
|
+
byOrgId: legacySecrets?.byOrgId ?? {}
|
|
834
|
+
});
|
|
835
|
+
await this.metadata.write({
|
|
836
|
+
version: METADATA_VERSION,
|
|
837
|
+
serverUrl: v2.data.serverUrl,
|
|
838
|
+
webUrl: v2.data.webUrl,
|
|
839
|
+
user: v2.data.user,
|
|
840
|
+
activeOrgId: v2.data.activeOrgId,
|
|
841
|
+
orgs: v2.data.orgs,
|
|
842
|
+
secretStorage
|
|
843
|
+
});
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const v1 = credentialsSchemaV1.safeParse(raw);
|
|
847
|
+
if (v1.success && v1.data.organizationId && v1.data.apiKey) {
|
|
848
|
+
const orgs = [{
|
|
849
|
+
organizationId: v1.data.organizationId,
|
|
850
|
+
organizationName: v1.data.organizationName ?? "Unknown",
|
|
851
|
+
apiKeyId: v1.data.apiKeyId,
|
|
852
|
+
createdAt: v1.data.createdAt
|
|
853
|
+
}];
|
|
854
|
+
const secretStorage = await this.importLegacySecrets({
|
|
855
|
+
metadata: {
|
|
856
|
+
version: METADATA_VERSION,
|
|
857
|
+
serverUrl: v1.data.serverUrl,
|
|
858
|
+
webUrl: v1.data.webUrl,
|
|
859
|
+
user: v1.data.user,
|
|
860
|
+
activeOrgId: v1.data.organizationId,
|
|
861
|
+
orgs,
|
|
862
|
+
secretStorage: this.defaultSecretStorage
|
|
863
|
+
},
|
|
864
|
+
byOrgId: { [v1.data.organizationId]: v1.data.apiKey }
|
|
865
|
+
});
|
|
866
|
+
await this.metadata.write({
|
|
867
|
+
version: METADATA_VERSION,
|
|
868
|
+
serverUrl: v1.data.serverUrl,
|
|
869
|
+
webUrl: v1.data.webUrl,
|
|
870
|
+
user: v1.data.user,
|
|
871
|
+
activeOrgId: v1.data.organizationId,
|
|
872
|
+
orgs,
|
|
873
|
+
secretStorage
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async importLegacySecrets(input) {
|
|
878
|
+
if (this.defaultSecretStorage.kind === "file") {
|
|
879
|
+
await this.secrets.writeLegacySecrets({
|
|
880
|
+
version: 1,
|
|
881
|
+
byOrgId: input.byOrgId
|
|
882
|
+
});
|
|
883
|
+
return this.defaultSecretStorage;
|
|
884
|
+
}
|
|
885
|
+
try {
|
|
886
|
+
await this.writeSecretsToStorage(input.metadata, input.byOrgId, "keychain");
|
|
887
|
+
await this.secrets.clearLegacySecrets();
|
|
888
|
+
return KEYCHAIN_STORAGE;
|
|
889
|
+
} catch {
|
|
890
|
+
await this.secrets.writeLegacySecrets({
|
|
891
|
+
version: 1,
|
|
892
|
+
byOrgId: input.byOrgId
|
|
893
|
+
});
|
|
894
|
+
return LEGACY_FILE_STORAGE;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
async migrateFileSecretsToKeychain(metadata) {
|
|
898
|
+
const legacySecrets = await this.secrets.readLegacySecrets();
|
|
899
|
+
if (!legacySecrets) return;
|
|
900
|
+
await this.writeSecretsToStorage(metadata, legacySecrets.byOrgId, "keychain");
|
|
901
|
+
await this.secrets.clearLegacySecrets();
|
|
902
|
+
}
|
|
903
|
+
async writeSecretsToStorage(metadata, byOrgId, storageKind) {
|
|
904
|
+
for (const org of metadata.orgs) {
|
|
905
|
+
const apiKey = byOrgId[org.organizationId];
|
|
906
|
+
if (!apiKey) continue;
|
|
907
|
+
const ref = {
|
|
908
|
+
orgId: org.organizationId,
|
|
909
|
+
serverUrl: metadata.serverUrl
|
|
910
|
+
};
|
|
911
|
+
await this.secrets.setApiKey(ref, apiKey, storageKind);
|
|
912
|
+
if (await this.secrets.getApiKey(ref, storageKind) !== apiKey) throw new Error(`Could not verify stored API key for organization ${org.organizationId}.`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
/**
|
|
917
|
+
* Production singleton — the primary public API for credential storage. Tests
|
|
918
|
+
* should construct `new Credentials({ homeDir })` instead.
|
|
919
|
+
*/
|
|
920
|
+
const credentials = new Credentials();
|
|
921
|
+
const projectEntrySchema = z.object({
|
|
922
|
+
lastAccessed: z.string().min(1),
|
|
923
|
+
name: z.string().min(1).optional()
|
|
924
|
+
});
|
|
925
|
+
const projectsSchema = z.object({
|
|
926
|
+
version: z.literal(1),
|
|
927
|
+
projects: z.record(z.string(), projectEntrySchema),
|
|
928
|
+
lastProject: z.string().optional()
|
|
929
|
+
});
|
|
930
|
+
const PROJECTS_FILE = "projects.json";
|
|
931
|
+
const PROJECTS_VERSION = 1;
|
|
932
|
+
const PROJECTS_DEFAULTS = {
|
|
933
|
+
version: PROJECTS_VERSION,
|
|
934
|
+
projects: {}
|
|
935
|
+
};
|
|
936
|
+
/**
|
|
937
|
+
* Domain controller for tracked Keystroke projects.
|
|
938
|
+
*
|
|
939
|
+
* Backed by a single file under `~/.keystroke/projects.json`.
|
|
940
|
+
*
|
|
941
|
+
* Production code uses the `projects` singleton exported below. Tests
|
|
942
|
+
* construct a fresh instance with `homeDir` pointing at a tempdir.
|
|
943
|
+
*/
|
|
944
|
+
var Projects = class {
|
|
945
|
+
store;
|
|
946
|
+
constructor(options = {}) {
|
|
947
|
+
this.store = new Store({
|
|
948
|
+
name: PROJECTS_FILE,
|
|
949
|
+
version: PROJECTS_VERSION,
|
|
950
|
+
schema: projectsSchema,
|
|
951
|
+
defaults: PROJECTS_DEFAULTS,
|
|
952
|
+
homeDir: options.homeDir
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
/** Absolute path to the projects file. */
|
|
956
|
+
get filePath() {
|
|
957
|
+
return this.store.filePath;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Upsert a project entry. Fire-and-forget safe — catches all errors
|
|
961
|
+
* internally. Never throws. Call without `await` if you don't need to wait.
|
|
962
|
+
*
|
|
963
|
+
* Tracking is best-effort telemetry; we never want a failed write to bubble
|
|
964
|
+
* up to a CLI command and degrade UX.
|
|
965
|
+
*/
|
|
966
|
+
async track(projectPath, options) {
|
|
967
|
+
const absolutePath = path$1.resolve(projectPath);
|
|
968
|
+
try {
|
|
969
|
+
await this.store.update((data) => {
|
|
970
|
+
const previous = data.projects[absolutePath];
|
|
971
|
+
const entry = {
|
|
972
|
+
lastAccessed: (/* @__PURE__ */ new Date()).toISOString(),
|
|
973
|
+
...options?.name ? { name: options.name } : previous?.name ? { name: previous.name } : {}
|
|
974
|
+
};
|
|
975
|
+
return {
|
|
976
|
+
...data,
|
|
977
|
+
projects: {
|
|
978
|
+
...data.projects,
|
|
979
|
+
[absolutePath]: entry
|
|
980
|
+
},
|
|
981
|
+
lastProject: absolutePath
|
|
982
|
+
};
|
|
983
|
+
});
|
|
984
|
+
} catch {}
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Removes a project from tracking. Returns `true` if the project existed,
|
|
988
|
+
* `false` if it was not tracked. Errors propagate (this is invoked by an
|
|
989
|
+
* explicit user action; the caller should know if it failed).
|
|
990
|
+
*/
|
|
991
|
+
async untrack(projectPath) {
|
|
992
|
+
const absolutePath = path$1.resolve(projectPath);
|
|
993
|
+
const existing = await this.store.read();
|
|
994
|
+
if (!existing || !(absolutePath in existing.projects)) return false;
|
|
995
|
+
const { [absolutePath]: _removed, ...remaining } = existing.projects;
|
|
996
|
+
const newLastProject = existing.lastProject === absolutePath ? void 0 : existing.lastProject;
|
|
997
|
+
await this.store.write({
|
|
998
|
+
...existing,
|
|
999
|
+
projects: remaining,
|
|
1000
|
+
lastProject: newLastProject
|
|
1001
|
+
});
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Returns all tracked projects as an array of `{ path, lastAccessed, name? }`.
|
|
1006
|
+
* Empty array when nothing is tracked or the file is missing.
|
|
1007
|
+
*
|
|
1008
|
+
* Note: order is not guaranteed (matches `Object.entries` over a Record).
|
|
1009
|
+
* Callers that need a sort order should sort explicitly.
|
|
1010
|
+
*
|
|
1011
|
+
* Best-effort: corrupt files are treated as "no projects" rather than
|
|
1012
|
+
* throwing, since project tracking is telemetry data.
|
|
1013
|
+
*/
|
|
1014
|
+
async list() {
|
|
1015
|
+
const data = await this.readSafe();
|
|
1016
|
+
if (!data) return [];
|
|
1017
|
+
return Object.entries(data.projects).map(([projectPath, entry]) => ({
|
|
1018
|
+
path: projectPath,
|
|
1019
|
+
...entry
|
|
1020
|
+
}));
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Returns the most-recently-tracked project path, or `undefined` when none
|
|
1024
|
+
* has been recorded.
|
|
1025
|
+
*/
|
|
1026
|
+
async getLast() {
|
|
1027
|
+
return (await this.readSafe())?.lastProject;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Removes the projects file. Returns `true` if the file existed, `false`
|
|
1031
|
+
* if it did not.
|
|
1032
|
+
*/
|
|
1033
|
+
async clear() {
|
|
1034
|
+
return this.store.delete();
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Reads the underlying store but treats schema/JSON errors as "no projects"
|
|
1038
|
+
* rather than propagating. This preserves the previous best-effort contract
|
|
1039
|
+
* for the projects file.
|
|
1040
|
+
*/
|
|
1041
|
+
async readSafe() {
|
|
1042
|
+
try {
|
|
1043
|
+
return await this.store.read();
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
if (error instanceof SyntaxError) return null;
|
|
1046
|
+
if (error instanceof Error && error.message.startsWith("Invalid file at")) return null;
|
|
1047
|
+
throw error;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
/**
|
|
1052
|
+
* Production singleton — the primary public API for project tracking. Tests
|
|
1053
|
+
* should construct `new Projects({ homeDir })` instead.
|
|
1054
|
+
*/
|
|
1055
|
+
const projects = new Projects();
|
|
1056
|
+
/**
|
|
1057
|
+
* Returns the Keystroke temp directory path.
|
|
1058
|
+
*
|
|
1059
|
+
* - `getKeystrokeTmpDir({ projectRoot })` → `projectRoot/.keystroke/tmp` — for build
|
|
1060
|
+
* artifacts that require module resolution from the project (workflow-builder).
|
|
1061
|
+
* - `getKeystrokeTmpDir()` → `~/.keystroke/tmp` — for general temporary storage.
|
|
1062
|
+
*
|
|
1063
|
+
* Callers must create the directory (e.g. `mkdir(path, { recursive: true })`)
|
|
1064
|
+
* before use.
|
|
1065
|
+
*/
|
|
1066
|
+
function getKeystrokeTmpDir(options) {
|
|
1067
|
+
const baseDir = options?.projectRoot ? path$1.resolve(options.projectRoot) : os.homedir();
|
|
1068
|
+
return path$1.join(baseDir, KEYSTROKE_DIR, "tmp");
|
|
1069
|
+
}
|
|
1070
|
+
//#endregion
|
|
1071
|
+
export { projects as i, credentials as n, getKeystrokeTmpDir as r, Credentials as t };
|