@constraint/cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +92 -0
- package/README.md +203 -0
- package/bin/constraint.js +9 -0
- package/package.json +19 -0
- package/skills/constraint-skills/SKILL.md +44 -0
- package/skills/constraint-skills/agents/openai.yaml +4 -0
- package/src/api.js +37 -0
- package/src/arguments.js +26 -0
- package/src/auth-store.js +58 -0
- package/src/auth.js +103 -0
- package/src/cli.js +405 -0
- package/src/config.js +61 -0
- package/src/files.js +146 -0
- package/src/paths.js +83 -0
- package/src/prompts.js +83 -0
- package/src/setup.js +94 -0
- package/src/skills.js +209 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Constraint CLI Proprietary License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Constraint. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This Constraint CLI Proprietary License (the "License") governs use of the
|
|
6
|
+
Constraint command-line software distributed with this License (the
|
|
7
|
+
"Software"). By installing or using the Software, you agree to this License.
|
|
8
|
+
|
|
9
|
+
1. Limited License
|
|
10
|
+
|
|
11
|
+
Subject to this License and any applicable agreement governing your access to
|
|
12
|
+
Constraint's products or services (a "Service Agreement"), Constraint grants
|
|
13
|
+
you a limited, non-exclusive, non-transferable, non-sublicensable, revocable
|
|
14
|
+
license to install and use the Software solely to access and use Constraint
|
|
15
|
+
products and services that you are authorized to use.
|
|
16
|
+
|
|
17
|
+
2. Restrictions
|
|
18
|
+
|
|
19
|
+
Except where applicable law expressly permits otherwise, you may not:
|
|
20
|
+
|
|
21
|
+
(a) copy the Software except as reasonably necessary to install, use, or make
|
|
22
|
+
one backup copy of it;
|
|
23
|
+
|
|
24
|
+
(b) modify, adapt, translate, or create derivative works of the Software;
|
|
25
|
+
|
|
26
|
+
(c) distribute, publish, sublicense, sell, rent, lease, lend, transfer, or
|
|
27
|
+
otherwise make the Software available to any third party;
|
|
28
|
+
|
|
29
|
+
(d) reverse engineer, decompile, disassemble, or attempt to discover the source
|
|
30
|
+
code, underlying ideas, algorithms, or non-public interfaces of the Software;
|
|
31
|
+
|
|
32
|
+
(e) remove or alter proprietary notices contained in the Software; or
|
|
33
|
+
|
|
34
|
+
(f) use the Software to bypass access controls, security controls, usage limits,
|
|
35
|
+
or permissions of any Constraint product or service.
|
|
36
|
+
|
|
37
|
+
3. Service Agreement
|
|
38
|
+
|
|
39
|
+
Your access to Constraint products and services remains governed by the
|
|
40
|
+
applicable Service Agreement. If this License conflicts with a signed Service
|
|
41
|
+
Agreement, the signed Service Agreement controls to the extent of the conflict.
|
|
42
|
+
The Software does not grant access to any product, service, data, skill, or
|
|
43
|
+
other resource that you are not otherwise authorized to access.
|
|
44
|
+
|
|
45
|
+
4. Ownership
|
|
46
|
+
|
|
47
|
+
The Software is licensed, not sold. Constraint and its licensors retain all
|
|
48
|
+
right, title, and interest in and to the Software, including all intellectual
|
|
49
|
+
property rights. No rights are granted except those expressly stated in this
|
|
50
|
+
License.
|
|
51
|
+
|
|
52
|
+
5. Updates
|
|
53
|
+
|
|
54
|
+
Constraint may provide updates, modifications, or replacement versions of the
|
|
55
|
+
Software. Unless accompanied by different terms, those versions are governed
|
|
56
|
+
by this License. Constraint is not obligated to provide any update, support, or
|
|
57
|
+
maintenance.
|
|
58
|
+
|
|
59
|
+
6. Term and Termination
|
|
60
|
+
|
|
61
|
+
This License ends automatically if you materially breach it or if your
|
|
62
|
+
authorization to use the applicable Constraint products and services ends.
|
|
63
|
+
Upon termination, you must stop using and delete all copies of the Software in
|
|
64
|
+
your possession or control. Sections 2, 4, 7, 8, and 9 survive termination.
|
|
65
|
+
|
|
66
|
+
7. Disclaimer of Warranties
|
|
67
|
+
|
|
68
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE SOFTWARE IS PROVIDED "AS IS" AND
|
|
69
|
+
"AS AVAILABLE," WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED,
|
|
70
|
+
STATUTORY, OR OTHERWISE, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
71
|
+
PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. CONSTRAINT DOES NOT WARRANT
|
|
72
|
+
THAT THE SOFTWARE WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE.
|
|
73
|
+
|
|
74
|
+
8. Limitation of Liability
|
|
75
|
+
|
|
76
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, CONSTRAINT AND ITS LICENSORS WILL NOT BE
|
|
77
|
+
LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR
|
|
78
|
+
PUNITIVE DAMAGES, OR FOR ANY LOSS OF DATA, PROFITS, REVENUE, BUSINESS, OR
|
|
79
|
+
GOODWILL, ARISING OUT OF OR RELATED TO THE SOFTWARE, EVEN IF ADVISED OF THE
|
|
80
|
+
POSSIBILITY OF THOSE DAMAGES. TO THE MAXIMUM EXTENT PERMITTED BY LAW,
|
|
81
|
+
CONSTRAINT'S TOTAL LIABILITY ARISING OUT OF OR RELATED TO THE SOFTWARE WILL NOT
|
|
82
|
+
EXCEED THE AMOUNT PAID SPECIFICALLY FOR THE SOFTWARE DURING THE TWELVE MONTHS
|
|
83
|
+
BEFORE THE EVENT GIVING RISE TO LIABILITY.
|
|
84
|
+
|
|
85
|
+
9. General
|
|
86
|
+
|
|
87
|
+
You may not assign this License without Constraint's prior written consent.
|
|
88
|
+
Constraint may assign this License in connection with a merger, acquisition,
|
|
89
|
+
reorganization, or sale of assets. If any provision is unenforceable, it will
|
|
90
|
+
be enforced to the maximum extent permitted and the remaining provisions will
|
|
91
|
+
remain in effect. A failure to enforce a provision is not a waiver. This
|
|
92
|
+
License does not limit rights that cannot lawfully be limited.
|
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Constraint CLI
|
|
2
|
+
|
|
3
|
+
Install, publish, and report governed Agent Skills with Constraint.
|
|
4
|
+
|
|
5
|
+
The package is named `@constraint/cli`; after installation, the terminal
|
|
6
|
+
command is simply `constraint`.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Node.js 20 or newer
|
|
11
|
+
- A Constraint account with access to a WorkOS organization
|
|
12
|
+
- Codex, Claude Code, or both
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install --global @constraint/cli
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Confirm the installation:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
constraint --version
|
|
24
|
+
constraint --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Sign in
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
constraint login
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The CLI displays a verification URL and code, then uses WorkOS device
|
|
34
|
+
authorization to sign in. It never asks for a Constraint API key or client
|
|
35
|
+
secret.
|
|
36
|
+
|
|
37
|
+
Inspect or clear the current session:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
constraint whoami
|
|
41
|
+
constraint logout
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configure agents
|
|
45
|
+
|
|
46
|
+
Run the interactive setup:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
constraint setup
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or target agents explicitly:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
constraint setup --agent codex --agent claude-code
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Setup installs the `constraint-skills` companion skill and offers to add a
|
|
59
|
+
marked Constraint block to the agent's global instructions. Existing
|
|
60
|
+
`AGENTS.md` and `CLAUDE.md` content outside that block is preserved.
|
|
61
|
+
|
|
62
|
+
Preview the instruction change without applying it:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
constraint setup --agent codex --dry-run
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Discover skills
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
constraint skills list
|
|
72
|
+
constraint skills list --search research
|
|
73
|
+
constraint skills inspect account-research
|
|
74
|
+
constraint skills inspect account-research --files
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The server returns only skills the signed-in user is authorized to access.
|
|
78
|
+
There is no domain-selection flag for reading or installing skills.
|
|
79
|
+
|
|
80
|
+
## Install skills
|
|
81
|
+
|
|
82
|
+
Start the interactive installer:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
constraint skills install account-research
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Install for multiple agents in the current project:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
constraint skills install account-research \
|
|
92
|
+
--agent codex \
|
|
93
|
+
--agent claude-code \
|
|
94
|
+
--project
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Install globally:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
constraint skills install account-research --agent codex --global
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Project installations write a portable `skills-lock.json`. Global
|
|
104
|
+
installations use Constraint's private per-user configuration directory. The
|
|
105
|
+
installer prefers managed links so agents can share one package; pass `--copy`
|
|
106
|
+
to install independent copies. If links are unavailable, the installer falls
|
|
107
|
+
back to copying and records the mode that actually occurred.
|
|
108
|
+
|
|
109
|
+
The installer stages changes, replaces managed paths atomically, and refuses
|
|
110
|
+
to overwrite local edits.
|
|
111
|
+
|
|
112
|
+
List local installation records:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
constraint skills list --installed --project
|
|
116
|
+
constraint skills list --installed --global
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Update one skill or every skill in a scope:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
constraint skills update account-research --project
|
|
123
|
+
constraint skills update --all --project
|
|
124
|
+
constraint skills update --all --global
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Upload a skill
|
|
128
|
+
|
|
129
|
+
Create or edit a standard Agent Skill folder locally, then upload it:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
constraint skills upload ./account-research
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The folder must contain a root `SKILL.md` with `name` and `description` YAML
|
|
136
|
+
frontmatter. If the user can manage more than one Constraint domain, the CLI
|
|
137
|
+
asks which domain owns the skill. Skill versions are immutable; uploading
|
|
138
|
+
changed content creates the next version.
|
|
139
|
+
|
|
140
|
+
## Report skill runs
|
|
141
|
+
|
|
142
|
+
The installed companion skill directs an agent to start a run after loading a
|
|
143
|
+
governed skill:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
constraint skills run start account-research \
|
|
147
|
+
--client codex \
|
|
148
|
+
--session <session-id>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The command prints a run ID. After completing the work, the agent uploads the
|
|
152
|
+
real client transcript file for that run:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
constraint skills run complete <run-id> <transcript-path>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The CLI uploads the transcript exactly as it exists on disk. It does not create
|
|
159
|
+
a replacement transcript or modify the invoked skill.
|
|
160
|
+
|
|
161
|
+
Inspect completed runs:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
constraint skills run list
|
|
165
|
+
constraint skills run list --skill account-research
|
|
166
|
+
constraint skills run list --team sales_pipeline --user <user-id>
|
|
167
|
+
constraint skills run get <run-id>
|
|
168
|
+
constraint skills run get <run-id> --raw --output transcript.jsonl
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Team and user filters are enforced by Constraint's domain permissions.
|
|
172
|
+
|
|
173
|
+
## Automation
|
|
174
|
+
|
|
175
|
+
Use `--json` for machine-readable output, `--quiet` to suppress normal output,
|
|
176
|
+
and `--yes` to accept deterministic installer defaults. Non-interactive
|
|
177
|
+
environments skip the terminal wizard.
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
constraint skills install account-research \
|
|
181
|
+
--agent codex \
|
|
182
|
+
--project \
|
|
183
|
+
--yes \
|
|
184
|
+
--json
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Diagnostics
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
constraint doctor
|
|
191
|
+
constraint doctor --agent codex --agent claude-code
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`doctor` reports API authentication and whether each agent has the companion
|
|
195
|
+
skill and managed instruction block.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
Constraint CLI is proprietary software. Use is permitted only under the
|
|
200
|
+
[Constraint CLI Proprietary License](./LICENSE) and any applicable agreement
|
|
201
|
+
governing access to Constraint products and services.
|
|
202
|
+
|
|
203
|
+
Copyright (c) 2026 Constraint. All rights reserved.
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@constraint/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Install, publish, and report Agent Skills with Constraint",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "constraint": "bin/constraint.js" },
|
|
7
|
+
"files": ["bin", "src", "skills"],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"check": "node --check bin/constraint.js && node --check src/cli.js",
|
|
10
|
+
"test": "node --test"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@clack/prompts": "1.7.0",
|
|
14
|
+
"picocolors": "1.1.1"
|
|
15
|
+
},
|
|
16
|
+
"engines": { "node": ">=20" },
|
|
17
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
18
|
+
"publishConfig": { "access": "public", "provenance": true }
|
|
19
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: constraint-skills
|
|
3
|
+
description: Report Agent Skill invocations to Constraint. Use whenever an agent invokes a skill installed from or governed by Constraint, including nested or multiple skill invocations in one client session.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Constraint Skills
|
|
7
|
+
|
|
8
|
+
Record each invocation without editing the invoked skill or changing its work.
|
|
9
|
+
|
|
10
|
+
## Start a run
|
|
11
|
+
|
|
12
|
+
Immediately after selecting and loading a skill, run:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
constraint skills run start <skill-name> --client <client> --session <session-id>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Use the invoked skill's frontmatter `name`. Normalize the client to `codex` or
|
|
19
|
+
`claude-code`. Use the current client session identifier. Retain the exact run ID
|
|
20
|
+
printed by the command. If one session invokes multiple skills, start and retain
|
|
21
|
+
a distinct run ID for each invocation.
|
|
22
|
+
|
|
23
|
+
If the command fails, report the failure briefly and continue the user's work.
|
|
24
|
+
Do not fabricate a run ID.
|
|
25
|
+
|
|
26
|
+
## Complete a run
|
|
27
|
+
|
|
28
|
+
After finishing the invoked skill's work and before ending the response, locate
|
|
29
|
+
the current session's real JSON or JSONL transcript file and run:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
constraint skills run complete <run-id> <transcript-path>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Pass the explicit local path. The CLI uploads the whole transcript file as it
|
|
36
|
+
exists at that moment and completes only that run. Never create a replacement
|
|
37
|
+
transcript, summarize it, pass a temporary file, or guess a path.
|
|
38
|
+
|
|
39
|
+
For multiple invocations, complete each retained run separately. Overlapping
|
|
40
|
+
full-session transcript prefixes are expected. If the transcript path cannot be
|
|
41
|
+
determined, leave the run started and tell the user that reporting could not be
|
|
42
|
+
completed.
|
|
43
|
+
|
|
44
|
+
Do not run completion hooks, background flushes, or pending-run scans.
|
package/src/api.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { accessToken } from './auth.js';
|
|
2
|
+
|
|
3
|
+
export class ConstraintApiError extends Error {
|
|
4
|
+
constructor(status, code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function apiRequest(config, requestPath, init = {}) {
|
|
12
|
+
const token = await accessToken(config);
|
|
13
|
+
const response = await fetch(`${config.apiUrl}${requestPath}`, {
|
|
14
|
+
...init,
|
|
15
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...init.headers },
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
let data = null;
|
|
19
|
+
try {
|
|
20
|
+
data = await response.json();
|
|
21
|
+
} catch {
|
|
22
|
+
data = null;
|
|
23
|
+
}
|
|
24
|
+
throw new ConstraintApiError(
|
|
25
|
+
response.status,
|
|
26
|
+
data?.error?.code || 'request_failed',
|
|
27
|
+
data?.error?.message || `Constraint API request failed (${response.status})`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (init.rawResponse) return response;
|
|
31
|
+
if (response.status === 204) return null;
|
|
32
|
+
return response.json();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function jsonRequest(body) {
|
|
36
|
+
return { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
|
37
|
+
}
|
package/src/arguments.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function takeFlag(args, name) {
|
|
2
|
+
const index = args.indexOf(name);
|
|
3
|
+
if (index < 0) return false;
|
|
4
|
+
args.splice(index, 1);
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function takeOption(args, name, { multiple = false } = {}) {
|
|
9
|
+
const values = [];
|
|
10
|
+
for (let index = 0; index < args.length; ) {
|
|
11
|
+
if (args[index] !== name) {
|
|
12
|
+
index += 1;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (index + 1 >= args.length || args[index + 1].startsWith('--')) throw new Error(`${name} requires a value`);
|
|
16
|
+
values.push(args[index + 1]);
|
|
17
|
+
args.splice(index, 2);
|
|
18
|
+
if (!multiple) break;
|
|
19
|
+
}
|
|
20
|
+
return multiple ? values : values[0];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function rejectUnknown(args) {
|
|
24
|
+
const unknown = args.find((value) => value.startsWith('-'));
|
|
25
|
+
if (unknown) throw new Error(`Unknown option: ${unknown}`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { configDirectory } from './paths.js';
|
|
6
|
+
|
|
7
|
+
const SERVICE = 'com.constraint.cli';
|
|
8
|
+
|
|
9
|
+
function useKeychain() {
|
|
10
|
+
return process.platform === 'darwin' && process.env.CONSTRAINT_TOKEN_STORE !== 'file';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function account(apiUrl) {
|
|
14
|
+
return new URL(apiUrl).host;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function keychain(args) {
|
|
18
|
+
return spawnSync('security', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tokenFile() {
|
|
22
|
+
return path.join(configDirectory(), 'auth.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readTokens(apiUrl) {
|
|
26
|
+
if (useKeychain()) {
|
|
27
|
+
const result = keychain(['find-generic-password', '-s', SERVICE, '-a', account(apiUrl), '-w']);
|
|
28
|
+
if (result.status === 0 && result.stdout.trim()) return JSON.parse(result.stdout.trim());
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(await fs.readFile(tokenFile(), 'utf8'));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error && error.code === 'ENOENT') return null;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function writeTokens(apiUrl, tokens) {
|
|
40
|
+
if (useKeychain()) {
|
|
41
|
+
const result = keychain([
|
|
42
|
+
'add-generic-password', '-U', '-s', SERVICE, '-a', account(apiUrl), '-w', JSON.stringify(tokens),
|
|
43
|
+
]);
|
|
44
|
+
if (result.status !== 0) throw new Error(`Could not store login in macOS Keychain: ${result.stderr.trim()}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await fs.mkdir(configDirectory(), { recursive: true, mode: 0o700 });
|
|
48
|
+
await fs.writeFile(tokenFile(), `${JSON.stringify(tokens)}\n`, { mode: 0o600 });
|
|
49
|
+
await fs.chmod(tokenFile(), 0o600);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function deleteTokens(apiUrl) {
|
|
53
|
+
if (useKeychain()) {
|
|
54
|
+
keychain(['delete-generic-password', '-s', SERVICE, '-a', account(apiUrl)]);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await fs.rm(tokenFile(), { force: true });
|
|
58
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { deleteTokens, readTokens, writeTokens } from './auth-store.js';
|
|
4
|
+
import { fetchCliConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
7
|
+
|
|
8
|
+
function sleep(milliseconds) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function openBrowser(url) {
|
|
13
|
+
if (process.env.CONSTRAINT_NO_BROWSER === '1') return;
|
|
14
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
15
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
16
|
+
try {
|
|
17
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
18
|
+
child.unref();
|
|
19
|
+
} catch {
|
|
20
|
+
// The printed URL is always the fallback.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function formPost(url, values) {
|
|
25
|
+
return fetch(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
28
|
+
body: new URLSearchParams(values),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function login(config, output) {
|
|
33
|
+
const oauth = await fetchCliConfig(config.apiUrl);
|
|
34
|
+
const authorization = await formPost(oauth.device_authorization_endpoint, { client_id: oauth.workos_client_id });
|
|
35
|
+
const request = await authorization.json();
|
|
36
|
+
if (!authorization.ok) throw new Error(request.error_description || request.error || 'Device authorization failed');
|
|
37
|
+
|
|
38
|
+
output(`Open ${request.verification_uri}`);
|
|
39
|
+
output(`Enter code: ${request.user_code}`);
|
|
40
|
+
if (request.verification_uri_complete) openBrowser(request.verification_uri_complete);
|
|
41
|
+
|
|
42
|
+
let interval = Number(request.interval || 5);
|
|
43
|
+
const expiresAt = Date.now() + Number(request.expires_in || 300) * 1000;
|
|
44
|
+
while (Date.now() < expiresAt) {
|
|
45
|
+
await sleep(interval * 1000);
|
|
46
|
+
const response = await formPost(oauth.token_endpoint, {
|
|
47
|
+
grant_type: DEVICE_GRANT,
|
|
48
|
+
device_code: request.device_code,
|
|
49
|
+
client_id: oauth.workos_client_id,
|
|
50
|
+
});
|
|
51
|
+
const data = await response.json();
|
|
52
|
+
if (response.ok) {
|
|
53
|
+
await writeTokens(config.apiUrl, {
|
|
54
|
+
...data,
|
|
55
|
+
client_id: oauth.workos_client_id,
|
|
56
|
+
token_endpoint: oauth.token_endpoint,
|
|
57
|
+
});
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
if (data.error === 'authorization_pending') continue;
|
|
61
|
+
if (data.error === 'slow_down') {
|
|
62
|
+
interval += 5;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
throw new Error(data.error_description || data.error || 'Authentication failed');
|
|
66
|
+
}
|
|
67
|
+
throw new Error('Authentication timed out. Run constraint login again.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function tokenExpiresSoon(token) {
|
|
71
|
+
try {
|
|
72
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
73
|
+
return !payload.exp || payload.exp * 1000 < Date.now() + 30_000;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function refresh(config, tokens) {
|
|
80
|
+
if (!tokens.refresh_token || !tokens.client_id || !tokens.token_endpoint) return null;
|
|
81
|
+
const response = await formPost(tokens.token_endpoint, {
|
|
82
|
+
grant_type: 'refresh_token',
|
|
83
|
+
refresh_token: tokens.refresh_token,
|
|
84
|
+
client_id: tokens.client_id,
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) return null;
|
|
87
|
+
const refreshed = await response.json();
|
|
88
|
+
const next = { ...tokens, ...refreshed };
|
|
89
|
+
await writeTokens(config.apiUrl, next);
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function accessToken(config) {
|
|
94
|
+
let tokens = await readTokens(config.apiUrl);
|
|
95
|
+
if (!tokens?.access_token) throw new Error('Not logged in. Run constraint login.');
|
|
96
|
+
if (tokenExpiresSoon(tokens.access_token)) tokens = (await refresh(config, tokens)) || tokens;
|
|
97
|
+
if (tokenExpiresSoon(tokens.access_token)) throw new Error('Login expired. Run constraint login.');
|
|
98
|
+
return tokens.access_token;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function logout(config) {
|
|
102
|
+
await deleteTokens(config.apiUrl);
|
|
103
|
+
}
|