archelps 0.1.1__py3-none-any.whl
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.
- arc_connect/LICENSE +21 -0
- arc_connect/README.md +200 -0
- arc_connect/__init__.py +3 -0
- arc_connect/__main__.py +3 -0
- arc_connect/cli.py +148 -0
- arc_connect/config.py +180 -0
- arc_connect/contract.py +125 -0
- arc_connect/data.schema.json +104 -0
- arc_connect/oauth.py +472 -0
- arc_connect/onboarding.py +620 -0
- arc_connect/scanner.py +212 -0
- arc_connect/upload.py +153 -0
- archelps-0.1.1.dist-info/METADATA +224 -0
- archelps-0.1.1.dist-info/RECORD +18 -0
- archelps-0.1.1.dist-info/WHEEL +5 -0
- archelps-0.1.1.dist-info/entry_points.txt +2 -0
- archelps-0.1.1.dist-info/licenses/LICENSE +21 -0
- archelps-0.1.1.dist-info/top_level.txt +1 -0
arc_connect/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arc
|
|
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.
|
arc_connect/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Arc CLI
|
|
2
|
+
|
|
3
|
+
Arc CLI's Connect commands record literal Arc Item markers in Git and upload file/line
|
|
4
|
+
locations to Arc. It uses Python 3.11 or later and Click, with plain terminal prompts. The
|
|
5
|
+
standalone package is MIT licensed; Arc's application/backend licence is unchanged.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
python -m pip install --upgrade archelps
|
|
9
|
+
arc connect init
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Setup and generated CI use the latest published package without a version
|
|
13
|
+
constraint. `--upgrade` also updates an existing installation.
|
|
14
|
+
|
|
15
|
+
The executable is `arc`. Connect groups the existing Git workflow under
|
|
16
|
+
`arc connect init`, `arc connect scan` and `arc connect upload`; `arc logout`
|
|
17
|
+
clears the saved sign-in. Setup handles browser sign-in. There is no separate
|
|
18
|
+
login command or general API command group.
|
|
19
|
+
|
|
20
|
+
Run setup in an interactive terminal inside a GitHub.com or GitLab.com repository.
|
|
21
|
+
Self-hosted GitLab is not currently supported.
|
|
22
|
+
Sign in through Cognito in the browser, then return to the terminal. Choose one
|
|
23
|
+
Organization and one or more Programs by name or number; confirm each Program's
|
|
24
|
+
repository-specific marker label immediately after selection. Existing registered
|
|
25
|
+
labels are frozen and reused. Confirm the repository/default branch, then choose
|
|
26
|
+
GitHub Actions, GitLab CI or other CI independently of the repository host. Declining
|
|
27
|
+
repository confirmation cancels setup; rerun with `--repository-url` and
|
|
28
|
+
`--default-branch` to correct it.
|
|
29
|
+
|
|
30
|
+
Setup writes secrets-free `.arc/config.json` with `schema_version`, `api_url`,
|
|
31
|
+
`organization_id`, `git`, `ci`, `programs` and `marker_instructions`. `git` contains
|
|
32
|
+
provider, canonical repository URL and default branch. Each Program entry contains
|
|
33
|
+
`program_id`, frozen `label`, `source_id` and `token_env` (`ARC_TOKEN_<LABEL>`).
|
|
34
|
+
Custom setup configuration paths must stay inside the repository for CI. Commit
|
|
35
|
+
the configuration and generated CI file, including the config if Git ignore rules
|
|
36
|
+
exclude it. Setup creates no hooks, tracked mapping/data file or customer README.
|
|
37
|
+
|
|
38
|
+
## Credentials and recovery
|
|
39
|
+
|
|
40
|
+
Setup automatically creates one personal write token per Program/source through
|
|
41
|
+
the OAuth-only `POST /programs/{program_id}/code-links/sources/{source_id}/token`
|
|
42
|
+
route. Source setup requires `program.manage`; token issuance also requires
|
|
43
|
+
`program.edit` and the normal personal-token membership/MFA rules. Write mode does
|
|
44
|
+
not grant Program permissions. Uploads recheck current authority.
|
|
45
|
+
|
|
46
|
+
Returned secrets are saved outside the repository in a private
|
|
47
|
+
`~/.arc-connect/ci-<hash>.json`, partitioned by checkout, API target and signed-in
|
|
48
|
+
account. The CLI prints only that path and CI secret names. Read the private file
|
|
49
|
+
and paste each value into its named CI secret or masked/protected variable, such
|
|
50
|
+
as `ARC_TOKEN_BRAKES`. Upload reads these environment variables; it does not read
|
|
51
|
+
the private handoff file.
|
|
52
|
+
|
|
53
|
+
Repeated init reuses saved settings and local credentials. A secrets-free draft
|
|
54
|
+
under Git's private metadata preserves selected Programs/labels during interrupted
|
|
55
|
+
registration. Configuration is saved before token issuance/CI generation. If an
|
|
56
|
+
existing server token has no local copy, setup requires an explicit replacement
|
|
57
|
+
choice; declining preserves it. `arc connect init --replace-tokens` deliberately
|
|
58
|
+
revokes and replaces only the current user's configured source tokens. Update CI
|
|
59
|
+
secrets afterward. No setup mutation is automatically retried after an uncertain
|
|
60
|
+
response.
|
|
61
|
+
|
|
62
|
+
Use `arc connect init --reconfigure` to choose Organization, Programs, repository
|
|
63
|
+
or CI again. An explicit API target conflicting with saved setup fails before
|
|
64
|
+
authentication unless reconfiguration is requested. Repeating init reconnects a
|
|
65
|
+
removed source through the existing source service. Superseded hooks/root files
|
|
66
|
+
have no compatibility path and are not deleted automatically.
|
|
67
|
+
|
|
68
|
+
Cognito credentials are cached separately at `~/.arc-connect/tokens.json`, with
|
|
69
|
+
private permissions, PKCE S256, state validation and domain/client/resource/scope
|
|
70
|
+
binding. Redirects do not receive bearer credentials. An expired refresh credential
|
|
71
|
+
reopens sign-in. `arc logout` clears a corrupt cache or revokes the cached
|
|
72
|
+
refresh token and removes the cache; it warns when revocation cannot be confirmed.
|
|
73
|
+
Issued access tokens can remain valid for up to 15 minutes. Personal CI tokens
|
|
74
|
+
remain separately managed.
|
|
75
|
+
|
|
76
|
+
HTTPS uses Python's default trusted certificates plus certifi's bundled roots.
|
|
77
|
+
An explicit `SSL_CERT_FILE` or `SSL_CERT_DIR` selects only that configured trust
|
|
78
|
+
store. Certificate errors retain saved credentials and stop with a clear error;
|
|
79
|
+
they do not reopen sign-in or retry uploads. Certificate and hostname checks stay enabled.
|
|
80
|
+
|
|
81
|
+
Production defaults are API transport `https://api.archelps.com/api/v1/public`,
|
|
82
|
+
Cognito domain `https://arc5-production.auth.eu-west-2.amazoncognito.com`, public
|
|
83
|
+
API client `3foiu1p2hb5nm09dc95g5c4m0j`, and audience/scope resource
|
|
84
|
+
`https://arc5-api.onrender.com/api/v1/public`. Transport and audience are distinct.
|
|
85
|
+
Use `--api-url`, `--cognito-domain`, `--client-id`, `--resource` and `--cache` for
|
|
86
|
+
another environment. `ARC_API_URL`, `ARC_COGNITO_DOMAIN`, `COGNITO_API_CLIENT_IDS`
|
|
87
|
+
and `ARC_API_RESOURCE` are also supported. Custom OAuth cache paths must remain
|
|
88
|
+
outside Git; the CI handoff is saved beside that cache.
|
|
89
|
+
|
|
90
|
+
## Literal markers and scanning
|
|
91
|
+
|
|
92
|
+
Copy the real marker from an Arc Item and place it near associated code or tests:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# ARC-BRAKES-REQ-001
|
|
96
|
+
def stop():
|
|
97
|
+
...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The syntax is `ARC-<PROGRAM_LABEL>-<ITEM_IDENTIFIER>`. Labels use uppercase ASCII
|
|
101
|
+
letters, digits and underscores, start with a letter and have at most 32 characters.
|
|
102
|
+
Canonical Item prefixes are `SYS`, `REQ`, `PROP`, `TST`, `TPL` and `RSK`, followed
|
|
103
|
+
by at least three digits. Leading zeros and the literal marker are preserved;
|
|
104
|
+
several markers can share a line. Unicode word characters and hyphens prevent
|
|
105
|
+
matches embedded in longer identifiers. Never invent identifiers or substitute
|
|
106
|
+
import identifiers. Coding agents can read `.arc/config.json` for configured labels
|
|
107
|
+
and marker guidance. A code link is not proof that an Item's obligation is met.
|
|
108
|
+
|
|
109
|
+
`arc connect scan` prints deterministic schema `1.0.0` JSON from one exact `HEAD` commit
|
|
110
|
+
without writing files or changing the index. Sequential Git search scans tracked
|
|
111
|
+
regular text; binary/LFS/submodule/symlink payloads, `.arc/` and a custom config
|
|
112
|
+
path are excluded. There is no AI, language parser, concurrency or incremental
|
|
113
|
+
cache. Git failures never produce successful empty reports. `--repo` selects a
|
|
114
|
+
checkout and `--config` a secrets-free configuration path relative to its Git root;
|
|
115
|
+
Connect's common options are accepted before or after its `init`, `scan` or
|
|
116
|
+
`upload` subcommand.
|
|
117
|
+
|
|
118
|
+
## CI reports
|
|
119
|
+
|
|
120
|
+
GitHub Actions generates `.github/workflows/arc.yml`: a separate default-branch
|
|
121
|
+
push workflow that checks out the exact event SHA, installs the latest published
|
|
122
|
+
package and runs `arc connect upload` with `continue-on-error: true`.
|
|
123
|
+
|
|
124
|
+
GitHub, GitLab and other-CI instructions all install with
|
|
125
|
+
`python -m pip install --upgrade archelps`, without a version constraint.
|
|
126
|
+
|
|
127
|
+
GitLab CI generates `.arc/gitlab-ci.yml`; append the printed include to your
|
|
128
|
+
existing `.gitlab-ci.yml`, merging with existing includes:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
include:
|
|
132
|
+
- local: '/.arc/gitlab-ci.yml'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Select GitLab CI during setup, or run `arc connect init --ci gitlab`. In the
|
|
136
|
+
GitLab project, open **Settings → CI/CD → Variables** and add each named
|
|
137
|
+
`ARC_TOKEN_<LABEL>` from the private handoff file as a masked variable. Use
|
|
138
|
+
protected variables only when the configured default branch is protected.
|
|
139
|
+
Commit `.arc/config.json`, `.arc/gitlab-ci.yml`, the include and your Item markers,
|
|
140
|
+
then push to that branch. A runner must be available. Check the `arc` job's upload
|
|
141
|
+
output, then Arc's **Last sync** and the Item's **Code links**; the job allows
|
|
142
|
+
failure, so a successful overall pipeline does not prove the upload succeeded.
|
|
143
|
+
|
|
144
|
+
Its job uses default-branch push rules with `when: always`, stage `.post` and
|
|
145
|
+
`allow_failure: true`, so earlier job failures do not skip it. Global workflow
|
|
146
|
+
rules must still allow that pipeline to be created. Existing customer files and
|
|
147
|
+
symlinks are preserved; setup prints generated content for manual insertion when
|
|
148
|
+
needed and never rewrites the customer's GitLab pipeline. Changing CI removes only
|
|
149
|
+
Arc-generated files for the other CI choice and prints a reminder to remove an
|
|
150
|
+
obsolete GitLab include.
|
|
151
|
+
|
|
152
|
+
Other CI receives exact-checkout, secret and nonblocking-step instructions. After
|
|
153
|
+
checking out the exact event commit, pass the commit and branch together for a
|
|
154
|
+
detached checkout, using your CI's actual event variables:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
arc connect upload --commit-sha "$YOUR_CI_COMMIT_SHA" --branch "$YOUR_CI_BRANCH"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Configure the step to allow failure. GitHub/GitLab events are checked automatically;
|
|
161
|
+
a mismatched checkout fails before sending and other branches skip. Keep CI token
|
|
162
|
+
names from `.arc/config.json` and do not expose tokens to pull-request pipelines.
|
|
163
|
+
|
|
164
|
+
Upload scans one pinned commit and generates JSON in memory. One complete data
|
|
165
|
+
format, `schema_version: "1.0.0"`, contains the full `commit_sha`, `scanner_version`
|
|
166
|
+
and `git` repository identity with Program UUID/label marker locations. Each
|
|
167
|
+
Program is sent separately through its own token to
|
|
168
|
+
`POST /api/v1/public/programs/{program_id}/code-links/sources/{source_id}/reports`.
|
|
169
|
+
Reports are bounded to 32 MiB and 500,000 locations. Network, 429 and 5xx failures
|
|
170
|
+
use at most three total attempts with identical bodies; other API failures do not
|
|
171
|
+
retry. Missing/failed tokens do not prevent remaining Program uploads; the direct
|
|
172
|
+
command returns nonzero if any fail while generated CI remains nonblocking.
|
|
173
|
+
|
|
174
|
+
`data.schema.json` describes both scan output and uploads; there is no nested report
|
|
175
|
+
wrapper. Scan output can contain all configured Programs; each upload contains
|
|
176
|
+
exactly one Program matching its token and route. Config's independent
|
|
177
|
+
`schema_version` remains integer `1`, and the CLI package version is `0.1.1`.
|
|
178
|
+
Future data-schema releases must deploy server acceptance before publishing a CLI
|
|
179
|
+
that sends the new format. The current data schema remains `1.0.0`; no compatibility
|
|
180
|
+
implementation is added by this release policy.
|
|
181
|
+
No source text or secrets are uploaded. Arc retains five report bodies
|
|
182
|
+
and resolved projections, with older deduplication receipts. Invalid/failed reports
|
|
183
|
+
preserve current links; complete empty reports clear them. Duplicate retries never
|
|
184
|
+
promote old reports. Removal hides links and disables uploads; identical source
|
|
185
|
+
reconnection restores the retained current report. These links do not create Item
|
|
186
|
+
revisions or appear in historic model views.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
python -m pip install '.[test]'
|
|
192
|
+
# Run only tests created or edited for the current change; for example:
|
|
193
|
+
python -m pytest --confcutdir=. tests/test_setup.py::test_noninteractive_init_does_not_open_browser
|
|
194
|
+
python -m build
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The standalone `archelps` package root maps the existing `arc_connect` Python
|
|
198
|
+
module to this directory and does not package the Arc backend or its dependencies.
|
|
199
|
+
Click and certifi are the runtime dependencies. The internal module name and private
|
|
200
|
+
`~/.arc-connect` credential location are unchanged.
|
arc_connect/__init__.py
ADDED
arc_connect/__main__.py
ADDED
arc_connect/cli.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Arc CLI with interactive Connect setup and unattended exact-commit reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from . import __version__, config, onboarding as setup, scanner, upload
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _common(function):
|
|
15
|
+
for name, help_text in (
|
|
16
|
+
("repo", "Git repository directory (default: current directory)"),
|
|
17
|
+
("config", "Secrets-free config path, relative to Git root"),
|
|
18
|
+
("api-url", "API transport ending in /api/v1/public"),
|
|
19
|
+
("cognito-domain", "Cognito Managed Login HTTPS origin"),
|
|
20
|
+
("client-id", "Public Cognito API client ID"),
|
|
21
|
+
("resource", "Cognito API audience and scope resource"),
|
|
22
|
+
("cache", "Private OAuth cache outside the repository"),
|
|
23
|
+
):
|
|
24
|
+
function = click.option("--" + name, default=None, help=help_text)(function)
|
|
25
|
+
return function
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _arguments(context, values):
|
|
29
|
+
result = {
|
|
30
|
+
**context.parent.params,
|
|
31
|
+
**{key: value for key, value in values.items() if value is not None},
|
|
32
|
+
}
|
|
33
|
+
result["config"] = result.get("config") or config.CONFIG_NAME
|
|
34
|
+
return Namespace(**result)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group()
|
|
38
|
+
@click.version_option(__version__, prog_name="arc")
|
|
39
|
+
def app():
|
|
40
|
+
"""Arc CLI."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.group()
|
|
44
|
+
@_common
|
|
45
|
+
def connect(**_options):
|
|
46
|
+
"""Connect literal Arc Item markers in Git to Arc Programs."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@connect.command()
|
|
50
|
+
@_common
|
|
51
|
+
@click.option("--provider", type=click.Choice(["github", "gitlab"]))
|
|
52
|
+
@click.option("--repository-url", help="Repository HTTPS URL (otherwise origin)")
|
|
53
|
+
@click.option("--default-branch", help="Repository default branch")
|
|
54
|
+
@click.option("--ci", type=click.Choice(["github", "gitlab", "other"]))
|
|
55
|
+
@click.option("--reconfigure", is_flag=True, help="Choose Organization, Programs and CI again")
|
|
56
|
+
@click.option(
|
|
57
|
+
"--replace-tokens", is_flag=True, help="Revoke and replace this user's configured source tokens"
|
|
58
|
+
)
|
|
59
|
+
@click.pass_context
|
|
60
|
+
def init(context, **values):
|
|
61
|
+
"""Sign in, choose Programs and generate CI configuration."""
|
|
62
|
+
args = _arguments(context, values)
|
|
63
|
+
root = scanner.repository_root(Path(args.repo or Path.cwd()))
|
|
64
|
+
setup.initialize(root, args)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@connect.command()
|
|
68
|
+
@_common
|
|
69
|
+
@click.pass_context
|
|
70
|
+
def scan(context, **values):
|
|
71
|
+
"""Print JSON from exact HEAD without modifying any files."""
|
|
72
|
+
args = _arguments(context, values)
|
|
73
|
+
root, value, excluded = _configured(args)
|
|
74
|
+
sha = scanner.git(root, "rev-parse", "--verify", "HEAD^{commit}").decode().strip()
|
|
75
|
+
click.echo(
|
|
76
|
+
scanner.mapping_bytes(
|
|
77
|
+
scanner.scan_repository(root, value, revision=sha, excluded_paths=excluded)
|
|
78
|
+
).decode(),
|
|
79
|
+
nl=False,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@connect.command("upload")
|
|
84
|
+
@_common
|
|
85
|
+
@click.option("--commit-sha", help="Exact checked-out CI commit (use with --branch)")
|
|
86
|
+
@click.option("--branch", help="CI branch name (use with --commit-sha)")
|
|
87
|
+
@click.pass_context
|
|
88
|
+
def upload_command(context, commit_sha, branch, **values):
|
|
89
|
+
"""Scan exact HEAD and upload on the configured default branch."""
|
|
90
|
+
args = _arguments(context, values)
|
|
91
|
+
root, value, excluded = _configured(args)
|
|
92
|
+
code = upload.upload_reports(
|
|
93
|
+
root, value, excluded_paths=excluded, commit_sha=commit_sha, branch=branch
|
|
94
|
+
)
|
|
95
|
+
if code:
|
|
96
|
+
raise click.exceptions.Exit(code)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
@_common
|
|
101
|
+
@click.pass_context
|
|
102
|
+
def logout(context, **values):
|
|
103
|
+
"""Revoke and remove the private OAuth sign-in cache."""
|
|
104
|
+
args = _arguments(context, values)
|
|
105
|
+
try:
|
|
106
|
+
root = scanner.repository_root(Path(getattr(args, "repo", None) or Path.cwd()))
|
|
107
|
+
except ValueError:
|
|
108
|
+
root = None
|
|
109
|
+
setup.logout(args, root)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _configured(args):
|
|
113
|
+
root = scanner.repository_root(Path(args.repo or Path.cwd()))
|
|
114
|
+
path = config.config_path(root, args.config)
|
|
115
|
+
value = config.read_config(path)
|
|
116
|
+
if args.api_url:
|
|
117
|
+
value = {**value, "api_url": config.validate_api_url(args.api_url)}
|
|
118
|
+
excluded = (str(path.relative_to(root)),) if path.is_relative_to(root) else ()
|
|
119
|
+
return root, value, excluded
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
124
|
+
try:
|
|
125
|
+
result = app.main(args=argv, prog_name="arc", standalone_mode=False)
|
|
126
|
+
return result if isinstance(result, int) else 0
|
|
127
|
+
except click.exceptions.Exit as exc:
|
|
128
|
+
return exc.exit_code
|
|
129
|
+
except click.ClickException as exc:
|
|
130
|
+
exc.show()
|
|
131
|
+
return exc.exit_code
|
|
132
|
+
except (OSError, ValueError) as exc:
|
|
133
|
+
click.echo(f"arc: {exc}", err=True)
|
|
134
|
+
return 1
|
|
135
|
+
except (click.Abort, KeyboardInterrupt, EOFError):
|
|
136
|
+
command = next(
|
|
137
|
+
(arg for arg in argv if arg in connect.commands or arg == "logout"), "operation"
|
|
138
|
+
)
|
|
139
|
+
if command == "init":
|
|
140
|
+
message = (
|
|
141
|
+
"Arc setup cancelled. Run arc connect init to resume saved Programs and labels."
|
|
142
|
+
)
|
|
143
|
+
elif command == "logout":
|
|
144
|
+
message = "Sign-out cancelled. Run arc logout to retry."
|
|
145
|
+
else:
|
|
146
|
+
message = "Arc operation cancelled."
|
|
147
|
+
click.echo(message, err=True)
|
|
148
|
+
return 130
|
arc_connect/config.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Small, secrets-free repository configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from .contract import canonical_repository, validate_git
|
|
13
|
+
from .scanner import git
|
|
14
|
+
|
|
15
|
+
CONFIG_NAME = ".arc/config.json"
|
|
16
|
+
MARKER_INSTRUCTIONS = "Use ARC-<PROGRAM_LABEL>-<ITEM_IDENTIFIER> near relevant code or tests. Copy real identifiers from Arc; never invent them."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def suggest_label(name: str) -> str:
|
|
20
|
+
words = re.findall(
|
|
21
|
+
r"[A-Z0-9]+", unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode().upper()
|
|
22
|
+
)
|
|
23
|
+
value = "_".join(words) or "PROGRAM"
|
|
24
|
+
if not value[0].isalpha():
|
|
25
|
+
value = "PROGRAM_" + value
|
|
26
|
+
return value[:32].rstrip("_")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _uuid(value: object, name: str) -> str:
|
|
30
|
+
try:
|
|
31
|
+
result = str(uuid.UUID(value))
|
|
32
|
+
except (ValueError, AttributeError, TypeError) as exc:
|
|
33
|
+
raise ValueError(f"{name} must be a canonical UUID.") from exc
|
|
34
|
+
if result != value:
|
|
35
|
+
raise ValueError(f"{name} must be a canonical UUID.")
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_api_url(value: object) -> str:
|
|
40
|
+
if not isinstance(value, str):
|
|
41
|
+
raise ValueError("API URL must end in /api/v1/public.")
|
|
42
|
+
try:
|
|
43
|
+
parsed = urlsplit(value)
|
|
44
|
+
port = parsed.port
|
|
45
|
+
if (
|
|
46
|
+
parsed.scheme not in {"https", "http"}
|
|
47
|
+
or not parsed.hostname
|
|
48
|
+
or parsed.username
|
|
49
|
+
or parsed.password
|
|
50
|
+
or parsed.query
|
|
51
|
+
or parsed.fragment
|
|
52
|
+
or parsed.path != "/api/v1/public"
|
|
53
|
+
or (port is not None and not 1 <= port <= 65535)
|
|
54
|
+
or (
|
|
55
|
+
parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}
|
|
56
|
+
)
|
|
57
|
+
or any(character.isspace() for character in value)
|
|
58
|
+
):
|
|
59
|
+
raise ValueError()
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"Use an HTTPS API URL ending in /api/v1/public (HTTP only on localhost)."
|
|
63
|
+
) from exc
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_branch(value: object) -> str:
|
|
68
|
+
if (
|
|
69
|
+
not isinstance(value, str)
|
|
70
|
+
or not value
|
|
71
|
+
or len(value) > 255
|
|
72
|
+
or value.startswith(("-", "/", "."))
|
|
73
|
+
or value.endswith(("/", ".", ".lock"))
|
|
74
|
+
or any(character.isspace() or ord(character) < 32 for character in value)
|
|
75
|
+
or any(character in "~^:?*[\\" for character in value)
|
|
76
|
+
or ".." in value
|
|
77
|
+
or "@{" in value
|
|
78
|
+
or "//" in value
|
|
79
|
+
or value == "@"
|
|
80
|
+
or any(part.startswith(".") or part.endswith(".lock") for part in value.split("/"))
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("Use a valid Git default branch name.")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_config(value: object, *, allow_pending: bool = False) -> dict:
|
|
87
|
+
if not isinstance(value, dict) or set(value) != {
|
|
88
|
+
"schema_version",
|
|
89
|
+
"api_url",
|
|
90
|
+
"organization_id",
|
|
91
|
+
"git",
|
|
92
|
+
"programs",
|
|
93
|
+
"ci",
|
|
94
|
+
"marker_instructions",
|
|
95
|
+
}:
|
|
96
|
+
raise ValueError("Invalid Arc configuration fields; never store tokens in this file.")
|
|
97
|
+
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
|
|
98
|
+
raise ValueError("Unsupported Arc configuration version.")
|
|
99
|
+
if not isinstance(value["ci"], str) or value["ci"] not in {"github", "gitlab", "other"}:
|
|
100
|
+
raise ValueError("Choose GitHub Actions, GitLab CI or other CI.")
|
|
101
|
+
if (
|
|
102
|
+
not isinstance(value["marker_instructions"], str)
|
|
103
|
+
or not 1 <= len(value["marker_instructions"]) <= 2000
|
|
104
|
+
):
|
|
105
|
+
raise ValueError("Include concise marker instructions.")
|
|
106
|
+
validate_api_url(value["api_url"])
|
|
107
|
+
_uuid(value["organization_id"], "Organization ID")
|
|
108
|
+
repo = value["git"]
|
|
109
|
+
if not isinstance(repo, dict) or set(repo) != {"provider", "repository_url", "default_branch"}:
|
|
110
|
+
raise ValueError("Invalid Git configuration fields.")
|
|
111
|
+
validate_branch(repo["default_branch"])
|
|
112
|
+
if not isinstance(value["programs"], list):
|
|
113
|
+
raise ValueError("Configure at least one Program.")
|
|
114
|
+
mapping_programs = []
|
|
115
|
+
for program in value["programs"]:
|
|
116
|
+
if not isinstance(program, dict) or set(program) != {
|
|
117
|
+
"program_id",
|
|
118
|
+
"label",
|
|
119
|
+
"source_id",
|
|
120
|
+
"token_env",
|
|
121
|
+
}:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"Invalid Program configuration fields; tokens belong in environment variables."
|
|
124
|
+
)
|
|
125
|
+
if program["source_id"] is not None or not allow_pending:
|
|
126
|
+
_uuid(program["source_id"], "Source ID")
|
|
127
|
+
if program["token_env"] != "ARC_TOKEN_" + str(program["label"]):
|
|
128
|
+
raise ValueError("Token environment variable must be ARC_TOKEN_<LABEL>.")
|
|
129
|
+
mapping_programs.append(
|
|
130
|
+
{"program_id": program["program_id"], "label": program["label"], "links": {}}
|
|
131
|
+
)
|
|
132
|
+
validate_git(
|
|
133
|
+
{
|
|
134
|
+
"provider": repo["provider"],
|
|
135
|
+
"repository_url": repo["repository_url"],
|
|
136
|
+
"programs": mapping_programs,
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def read_config(path: Path) -> dict:
|
|
143
|
+
if path.is_symlink():
|
|
144
|
+
raise ValueError("Arc configuration cannot be a symlink.")
|
|
145
|
+
try:
|
|
146
|
+
return validate_config(json.loads(path.read_text(encoding="utf-8")))
|
|
147
|
+
except FileNotFoundError as exc:
|
|
148
|
+
raise ValueError("No Arc configuration found. Run arc connect init.") from exc
|
|
149
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"Could not read .arc/config.json. Restore the configuration or run init."
|
|
152
|
+
) from exc
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def config_path(root: Path, value: str) -> Path:
|
|
156
|
+
path = Path(value)
|
|
157
|
+
return path if path.is_absolute() else root / path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def discover_repository(root: Path) -> dict[str, str]:
|
|
161
|
+
remote = git(root, "remote", "get-url", "origin").decode("utf-8").strip()
|
|
162
|
+
if match := re.fullmatch(r"git@(github\.com|gitlab\.com):(.+)", remote):
|
|
163
|
+
remote = "https://" + match[1] + "/" + match[2]
|
|
164
|
+
elif remote.startswith("ssh://git@"):
|
|
165
|
+
parsed = urlsplit(remote)
|
|
166
|
+
remote = "https://" + str(parsed.hostname) + parsed.path
|
|
167
|
+
host = urlsplit(remote).hostname
|
|
168
|
+
provider = {"github.com": "github", "gitlab.com": "gitlab"}.get(host)
|
|
169
|
+
if provider is None:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"Origin must point to GitHub.com or GitLab.com; provide --repository-url and --provider."
|
|
172
|
+
)
|
|
173
|
+
repository_url = canonical_repository(remote, provider)
|
|
174
|
+
reference = (
|
|
175
|
+
git(root, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD", allowed=(0, 1))
|
|
176
|
+
.decode()
|
|
177
|
+
.strip()
|
|
178
|
+
)
|
|
179
|
+
branch = reference.removeprefix("refs/remotes/origin/") if reference else ""
|
|
180
|
+
return {"provider": provider, "repository_url": repository_url, "default_branch": branch}
|