contree-cli 0.5.1__tar.gz → 0.6.0__tar.gz
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.
- {contree_cli-0.5.1 → contree_cli-0.6.0}/PKG-INFO +1 -1
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/agent.md +75 -1
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/arguments.py +12 -0
- contree_cli-0.6.0/contree_cli/cli/build.py +224 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/file.py +149 -3
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/images.py +12 -7
- contree_cli-0.6.0/contree_cli/cli/operation.py +157 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/ps.py +23 -13
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/run.py +29 -12
- contree_cli-0.6.0/contree_cli/docker/__init__.py +42 -0
- contree_cli-0.6.0/contree_cli/docker/context.py +161 -0
- contree_cli-0.6.0/contree_cli/docker/dockerignore.py +102 -0
- contree_cli-0.6.0/contree_cli/docker/keyword.py +94 -0
- contree_cli-0.6.0/contree_cli/docker/kw_add.py +132 -0
- contree_cli-0.6.0/contree_cli/docker/kw_arg.py +41 -0
- contree_cli-0.6.0/contree_cli/docker/kw_copy.py +178 -0
- contree_cli-0.6.0/contree_cli/docker/kw_env.py +33 -0
- contree_cli-0.6.0/contree_cli/docker/kw_from.py +119 -0
- contree_cli-0.6.0/contree_cli/docker/kw_run.py +190 -0
- contree_cli-0.6.0/contree_cli/docker/kw_skipped.py +30 -0
- contree_cli-0.6.0/contree_cli/docker/kw_user.py +31 -0
- contree_cli-0.6.0/contree_cli/docker/kw_workdir.py +37 -0
- contree_cli-0.6.0/contree_cli/docker/local_context.py +159 -0
- contree_cli-0.6.0/contree_cli/docker/parser.py +93 -0
- contree_cli-0.6.0/contree_cli/docker/url_fetch.py +303 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/manual.md +2 -1
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/mapped_file.py +11 -1
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/session.py +63 -3
- contree_cli-0.6.0/contree_cli/shell/argmap.py +69 -0
- contree_cli-0.6.0/contree_cli/shell/argspec.py +343 -0
- contree_cli-0.6.0/contree_cli/shell/cache.py +109 -0
- contree_cli-0.6.0/contree_cli/shell/completer.py +371 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/repl.py +256 -54
- contree_cli-0.6.0/contree_cli/shell/sources.py +495 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/skill_body.md +92 -4
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/types.py +5 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/pyproject.toml +1 -1
- contree_cli-0.5.1/contree_cli/shell/completer.py +0 -485
- {contree_cli-0.5.1 → contree_cli-0.6.0}/LICENSE +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/README.md +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/__init__.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/__main__.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/__init__.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/agent.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/auth.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/cat.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/cd.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/cp.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/env.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/kill.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/ls.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/session.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/show.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/skill.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/tag.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/use.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/client.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/config.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/log.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/man.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/migrations.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/output.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/py.typed +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/__init__.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/history.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/parser.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/trie.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/skill.py +0 -0
- {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/update_check.py +0 -0
|
@@ -134,7 +134,28 @@ Tag conventions:
|
|
|
134
134
|
Always search before building:
|
|
135
135
|
contree images --prefix=python-dev
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
Building from a Dockerfile:
|
|
138
|
+
When a project already ships a Dockerfile, prefer `contree build`
|
|
139
|
+
over hand-running each step. It executes FROM/RUN/COPY/WORKDIR/ENV
|
|
140
|
+
/ARG/USER against the API and caches every layer as a branch so
|
|
141
|
+
rebuilds are fast.
|
|
142
|
+
|
|
143
|
+
Layer cache is keyed by abspath(context), shared across invocations:
|
|
144
|
+
contree build . build ./Dockerfile, no tag
|
|
145
|
+
contree build . --tag myapp:dev build + tag the final image
|
|
146
|
+
contree build ./app --dockerfile ./app/Dockerfile.prod --tag svc:prod
|
|
147
|
+
contree build . --build-arg VERSION=1.2
|
|
148
|
+
contree build . --no-cache force rebuild
|
|
149
|
+
|
|
150
|
+
Supported directives: FROM, RUN, COPY, ADD (local paths only),
|
|
151
|
+
WORKDIR, ENV, ARG, USER. CMD/ENTRYPOINT/LABEL/EXPOSE/VOLUME/etc.
|
|
152
|
+
are parsed but skipped with a warning. Multi-stage (AS / --from)
|
|
153
|
+
is not yet supported.
|
|
154
|
+
|
|
155
|
+
.dockerignore is applied to every COPY/ADD walk on top of the
|
|
156
|
+
default exclude list (.git, __pycache__, node_modules, etc.).
|
|
157
|
+
|
|
158
|
+
More: contree build --help, contree images --help, contree tag --help
|
|
138
159
|
|
|
139
160
|
Files and directories
|
|
140
161
|
=====================
|
|
@@ -177,6 +198,32 @@ Pending files are injected into the next non-disposable run.
|
|
|
177
198
|
Explicit --file takes priority over pending files at same path.
|
|
178
199
|
Pending files are branch-aware.
|
|
179
200
|
|
|
201
|
+
Listing uploaded files:
|
|
202
|
+
contree file ls list all uploaded files in the project
|
|
203
|
+
contree file ls --since 1d narrow by upload time
|
|
204
|
+
contree file ls -q uuid + sha256 + source only (quiet)
|
|
205
|
+
contree -f json file ls JSON output for jq
|
|
206
|
+
|
|
207
|
+
Output joins remote files (uuid, sha256, size, created_at) with the
|
|
208
|
+
local upload cache. The SOURCE column shows whatever this machine
|
|
209
|
+
used to produce the file:
|
|
210
|
+
- absolute host path for files uploaded via `run --file` / `COPY`;
|
|
211
|
+
- https://... URL for files fetched via `ADD URL`.
|
|
212
|
+
|
|
213
|
+
IMPORTANT: SOURCE is resolved ONLY for files uploaded from this
|
|
214
|
+
specific machine. The mapping lives in the local SQLite cache (per
|
|
215
|
+
profile, under $CONTREE_HOME/cli/sessions/<profile>.db) keyed by
|
|
216
|
+
path+inode+mtime+size (for host paths) or by the URL itself (for
|
|
217
|
+
URL fetches), and is NOT shared between hosts. Rows show empty
|
|
218
|
+
SOURCE when:
|
|
219
|
+
- the file was uploaded from a different machine or by a teammate;
|
|
220
|
+
- the host file has been moved, renamed, or its inode/mtime/size
|
|
221
|
+
changed since upload (the cache key no longer matches);
|
|
222
|
+
- the upload happened before tracking landed (older entries
|
|
223
|
+
backfill on the next match).
|
|
224
|
+
An agent must not assume SOURCE is authoritative across hosts;
|
|
225
|
+
for cross-machine identity always use the remote UUID or sha256.
|
|
226
|
+
|
|
180
227
|
More: contree run --help, contree file --help
|
|
181
228
|
|
|
182
229
|
Execution modes
|
|
@@ -211,10 +258,31 @@ Piped stdin:
|
|
|
211
258
|
Detached mode (-d):
|
|
212
259
|
contree run -d -- long-running-task
|
|
213
260
|
contree ps check status
|
|
261
|
+
contree ps -a -S FAILED --since=1h recent failures
|
|
214
262
|
contree show UUID view result
|
|
215
263
|
contree session wait block until done
|
|
216
264
|
contree session wait UUID1 UUID2 wait for specific ops
|
|
217
265
|
|
|
266
|
+
Monitoring background operations:
|
|
267
|
+
Use the `operation` namespace (alias `op`) when juggling several
|
|
268
|
+
detached runs. `op ls` is `ps`; `op show` and `op cancel` accept
|
|
269
|
+
multiple UUIDs in one call.
|
|
270
|
+
|
|
271
|
+
contree op ls list operations (= ps)
|
|
272
|
+
contree op ls -a -S EXECUTING filter active runs
|
|
273
|
+
contree op show UUID1 UUID2 UUID3 inspect a batch in one call
|
|
274
|
+
contree op cancel UUID1 UUID2 cancel selected operations
|
|
275
|
+
contree op cancel --all cancel every active op (rare)
|
|
276
|
+
|
|
277
|
+
Fan-out + join pattern:
|
|
278
|
+
A=$(contree run -d -- make -C /work/a build | jq -r .uuid)
|
|
279
|
+
B=$(contree run -d -- make -C /work/b build | jq -r .uuid)
|
|
280
|
+
contree session wait "$A" "$B"
|
|
281
|
+
contree op show "$A" "$B"
|
|
282
|
+
|
|
283
|
+
Background checks are cheap: terminal results are cached locally,
|
|
284
|
+
so repeated `op show` / `show` calls do not re-hit the API.
|
|
285
|
+
|
|
218
286
|
Disposable mode (-D) — no image checkpoint:
|
|
219
287
|
contree run -D -- rm -rf /tmp/*
|
|
220
288
|
contree run -D -- cat /etc/passwd
|
|
@@ -352,11 +420,16 @@ All commands
|
|
|
352
420
|
|
|
353
421
|
use [IMAGE] Set or show session image (aliases: ci)
|
|
354
422
|
run [-- CMD] Spawn sandbox instance (aliases: r)
|
|
423
|
+
build [CONTEXT] Build image from Dockerfile (aliases: bd)
|
|
355
424
|
images List/import images (aliases: i, img)
|
|
356
425
|
tag [IMAGE] TAG Tag image (aliases: t)
|
|
357
426
|
ps List operations
|
|
358
427
|
kill UUID Cancel operation
|
|
359
428
|
show UUID Show operation result
|
|
429
|
+
operation list List operations (aliases: op ls)
|
|
430
|
+
operation show UUID... Show one or more operation results (aliases: op)
|
|
431
|
+
operation cancel UUID...
|
|
432
|
+
Cancel one or more operations (or --all)
|
|
360
433
|
ls [PATH] List files in image (no VM)
|
|
361
434
|
cat PATH Show file content (no VM)
|
|
362
435
|
cp PATH DEST Download file from image
|
|
@@ -364,6 +437,7 @@ All commands
|
|
|
364
437
|
env [KEY=VALUE ...] Session env vars (-d to unset)
|
|
365
438
|
file edit PATH Edit remote file via $EDITOR
|
|
366
439
|
file cp SRC DEST Stage local file for next run
|
|
440
|
+
file ls [-q] List uploaded files + local path (aliases: list)
|
|
367
441
|
session list List sessions (aliases: ls)
|
|
368
442
|
session branch [NAME] Create/list branches (aliases: br)
|
|
369
443
|
session checkout BRANCH Switch branch (aliases: co)
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
from contree_cli.cli import (
|
|
7
7
|
agent,
|
|
8
8
|
auth,
|
|
9
|
+
build,
|
|
9
10
|
cat,
|
|
10
11
|
cd,
|
|
11
12
|
cp,
|
|
@@ -14,6 +15,7 @@ from contree_cli.cli import (
|
|
|
14
15
|
images,
|
|
15
16
|
kill,
|
|
16
17
|
ls,
|
|
18
|
+
operation,
|
|
17
19
|
ps,
|
|
18
20
|
run,
|
|
19
21
|
session,
|
|
@@ -44,6 +46,9 @@ examples:
|
|
|
44
46
|
contree run --file ./src:/app/src -- make -C /app/src
|
|
45
47
|
contree images --prefix=ubuntu
|
|
46
48
|
contree ps -q
|
|
49
|
+
contree op ls same as `contree ps`
|
|
50
|
+
contree op show UUID1 UUID2 multi-UUID show
|
|
51
|
+
contree op cancel UUID1 UUID2 multi-UUID cancel (or --all)
|
|
47
52
|
contree show OPERATION_UUID
|
|
48
53
|
contree tag IMAGE_UUID latest
|
|
49
54
|
contree ls /etc list files in session image
|
|
@@ -206,11 +211,18 @@ def register(
|
|
|
206
211
|
|
|
207
212
|
register("use", "Set or show current session image", use.setup_parser, aliases=["ci"])
|
|
208
213
|
register("run", "Spawn a sandbox instance", run.setup_parser, aliases=["r"])
|
|
214
|
+
register("build", "Build image from Dockerfile", build.setup_parser, aliases=["bd"])
|
|
209
215
|
register("images", "List and import images", images.setup_parser, aliases=["i", "img"])
|
|
210
216
|
register("tag", "Tag an image", tag.setup_parser, aliases=["t"])
|
|
211
217
|
register("ps", "List operations/instances", ps.setup_parser)
|
|
212
218
|
register("kill", "Cancel an operation", kill.setup_parser)
|
|
213
219
|
register("show", "Show operation result", show.setup_parser)
|
|
220
|
+
register(
|
|
221
|
+
"operation",
|
|
222
|
+
"Manage operations (list/show/cancel)",
|
|
223
|
+
operation.setup_parser,
|
|
224
|
+
aliases=["op"],
|
|
225
|
+
)
|
|
214
226
|
register("ls", "List files in image", ls.setup_parser)
|
|
215
227
|
register("cat", "Show file content from image", cat.setup_parser)
|
|
216
228
|
register("cp", "Copy file from image to local path", cp.setup_parser)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Build an image from a Dockerfile.
|
|
2
|
+
|
|
3
|
+
Reads the Dockerfile at the given path (default ``<CONTEXT>/Dockerfile``)
|
|
4
|
+
and applies each directive against an isolated build session keyed by
|
|
5
|
+
the absolute path of the context directory. Successful layers are
|
|
6
|
+
materialised as branches named ``layer:<chain-hash>`` so that
|
|
7
|
+
re-running the same Dockerfile reuses prior work.
|
|
8
|
+
|
|
9
|
+
Supported directives (MVP): FROM, RUN, COPY, ADD (without URL/tar),
|
|
10
|
+
WORKDIR, ENV, ARG, USER. Other Dockerfile directives parse cleanly
|
|
11
|
+
but are skipped with a warning (CMD, ENTRYPOINT, LABEL, EXPOSE,
|
|
12
|
+
VOLUME, STOPSIGNAL, MAINTAINER, HEALTHCHECK, ONBUILD, SHELL).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import hashlib
|
|
19
|
+
import logging
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from contree_cli import (
|
|
24
|
+
CLIENT,
|
|
25
|
+
FORMATTER,
|
|
26
|
+
PROFILE,
|
|
27
|
+
SESSION_STORE,
|
|
28
|
+
ArgumentsProtocol,
|
|
29
|
+
SetupResult,
|
|
30
|
+
)
|
|
31
|
+
from contree_cli.docker import (
|
|
32
|
+
ArgKeyword,
|
|
33
|
+
BuildContext,
|
|
34
|
+
DockerKeyword,
|
|
35
|
+
FromKeyword,
|
|
36
|
+
LocalContext,
|
|
37
|
+
RunKeyword,
|
|
38
|
+
parse_dockerfile,
|
|
39
|
+
)
|
|
40
|
+
from contree_cli.docker.context import BUILD_TIMEOUT_DEFAULT
|
|
41
|
+
from contree_cli.session import SessionStore
|
|
42
|
+
from contree_cli.types import FLAGS
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
EPILOG = """\
|
|
47
|
+
examples:
|
|
48
|
+
contree build .
|
|
49
|
+
contree build . --tag myimage:latest
|
|
50
|
+
contree build --dockerfile ./Dockerfile.test ./app
|
|
51
|
+
contree build --build-arg VERSION=1.2 .
|
|
52
|
+
contree build --no-cache .
|
|
53
|
+
|
|
54
|
+
for coding agents:
|
|
55
|
+
mutating command, may create operations against the API
|
|
56
|
+
layer cache is per-context (session keyed by abspath(context))
|
|
57
|
+
use --no-cache to bypass cached layers and rebuild from scratch
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class BuildArgs(ArgumentsProtocol):
|
|
63
|
+
context: str = "."
|
|
64
|
+
dockerfile: str = ""
|
|
65
|
+
tag: str = ""
|
|
66
|
+
build_args: tuple[str, ...] = field(default_factory=tuple)
|
|
67
|
+
no_cache: bool = False
|
|
68
|
+
timeout: int = BUILD_TIMEOUT_DEFAULT
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_args(cls, ns: argparse.Namespace) -> BuildArgs:
|
|
72
|
+
return cls(
|
|
73
|
+
context=ns.context or ".",
|
|
74
|
+
dockerfile=ns.dockerfile or "",
|
|
75
|
+
tag=ns.tag or "",
|
|
76
|
+
build_args=tuple(ns.build_arg or ()),
|
|
77
|
+
no_cache=bool(ns.no_cache),
|
|
78
|
+
timeout=ns.timeout,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
83
|
+
p.add_argument(
|
|
84
|
+
"context",
|
|
85
|
+
nargs="?",
|
|
86
|
+
default=".",
|
|
87
|
+
help="Build context directory",
|
|
88
|
+
)
|
|
89
|
+
p.add_argument(
|
|
90
|
+
*FLAGS["dockerfile"],
|
|
91
|
+
default="",
|
|
92
|
+
metavar="PATH",
|
|
93
|
+
help="Dockerfile path (default: <context>/Dockerfile)",
|
|
94
|
+
)
|
|
95
|
+
p.add_argument(
|
|
96
|
+
*FLAGS["tag_name"],
|
|
97
|
+
default="",
|
|
98
|
+
metavar="NAME[:TAG]",
|
|
99
|
+
help="Tag the final image",
|
|
100
|
+
)
|
|
101
|
+
p.add_argument(
|
|
102
|
+
*FLAGS["build_arg"],
|
|
103
|
+
action="append",
|
|
104
|
+
default=[],
|
|
105
|
+
metavar="KEY=VALUE",
|
|
106
|
+
help="Build-time variable (repeatable)",
|
|
107
|
+
)
|
|
108
|
+
p.add_argument(
|
|
109
|
+
*FLAGS["no_cache"],
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="Ignore cached layers and rebuild",
|
|
112
|
+
)
|
|
113
|
+
p.add_argument(
|
|
114
|
+
*FLAGS["timeout"],
|
|
115
|
+
type=int,
|
|
116
|
+
default=BUILD_TIMEOUT_DEFAULT,
|
|
117
|
+
help="Timeout in seconds for each RUN step",
|
|
118
|
+
)
|
|
119
|
+
return cmd_build, BuildArgs
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_build(args: BuildArgs) -> int | None:
|
|
123
|
+
context_dir = Path(args.context).expanduser().resolve()
|
|
124
|
+
if not context_dir.is_dir():
|
|
125
|
+
logger.error("context %s is not a directory", context_dir)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
dockerfile_path = (
|
|
129
|
+
Path(args.dockerfile).expanduser()
|
|
130
|
+
if args.dockerfile
|
|
131
|
+
else context_dir / "Dockerfile"
|
|
132
|
+
)
|
|
133
|
+
if not dockerfile_path.is_file():
|
|
134
|
+
logger.error("Dockerfile %s not found", dockerfile_path)
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
text = dockerfile_path.read_text()
|
|
138
|
+
try:
|
|
139
|
+
directives = parse_dockerfile(text)
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
logger.error("Dockerfile parse error: %s", exc)
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
if not validate_first_directive(directives):
|
|
145
|
+
logger.error("Dockerfile must contain a FROM directive")
|
|
146
|
+
return 1
|
|
147
|
+
|
|
148
|
+
build_args = parse_build_args(args.build_args)
|
|
149
|
+
|
|
150
|
+
profile = PROFILE.get()
|
|
151
|
+
client = CLIENT.get()
|
|
152
|
+
session_key = make_session_key(context_dir)
|
|
153
|
+
store = SessionStore(profile.session_db_path, session_key)
|
|
154
|
+
SESSION_STORE.set(store)
|
|
155
|
+
|
|
156
|
+
ctx = BuildContext(
|
|
157
|
+
client=client,
|
|
158
|
+
store=store,
|
|
159
|
+
local=LocalContext.from_dir(context_dir),
|
|
160
|
+
build_args=build_args,
|
|
161
|
+
no_cache=args.no_cache,
|
|
162
|
+
timeout=args.timeout,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
for kw in directives:
|
|
167
|
+
kw.execute(ctx)
|
|
168
|
+
finalize_pending(ctx)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
logger.error("build failed: %s", exc)
|
|
171
|
+
return 1
|
|
172
|
+
|
|
173
|
+
if not ctx.last_image:
|
|
174
|
+
logger.error("build produced no image")
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
if args.tag:
|
|
178
|
+
client.patch_json(
|
|
179
|
+
f"/v1/images/{ctx.last_image}/tag",
|
|
180
|
+
{"tag": args.tag},
|
|
181
|
+
)
|
|
182
|
+
logger.info("tagged %s as %s", ctx.last_image, args.tag)
|
|
183
|
+
|
|
184
|
+
formatter = FORMATTER.get()
|
|
185
|
+
formatter(
|
|
186
|
+
image=ctx.last_image,
|
|
187
|
+
tag=args.tag,
|
|
188
|
+
session=session_key,
|
|
189
|
+
)
|
|
190
|
+
formatter.flush()
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate_first_directive(directives: list[DockerKeyword]) -> bool:
|
|
195
|
+
for d in directives:
|
|
196
|
+
if isinstance(d, FromKeyword):
|
|
197
|
+
return True
|
|
198
|
+
if isinstance(d, ArgKeyword):
|
|
199
|
+
continue
|
|
200
|
+
return False
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def parse_build_args(items: tuple[str, ...]) -> dict[str, str]:
|
|
205
|
+
out: dict[str, str] = {}
|
|
206
|
+
for item in items:
|
|
207
|
+
if "=" not in item:
|
|
208
|
+
raise ValueError(f"--build-arg expected KEY=VALUE, got {item!r}")
|
|
209
|
+
k, _, v = item.partition("=")
|
|
210
|
+
out[k] = v
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def make_session_key(context_dir: Path) -> str:
|
|
215
|
+
digest = hashlib.sha256(str(context_dir).encode()).hexdigest()
|
|
216
|
+
return f"build:{digest[:16]}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def finalize_pending(ctx: BuildContext) -> None:
|
|
220
|
+
"""If COPY/ADD left files pending, commit them via a trivial RUN."""
|
|
221
|
+
if not ctx.pending:
|
|
222
|
+
return
|
|
223
|
+
closer = RunKeyword(parts=(":",), shell_form=True)
|
|
224
|
+
closer.execute(ctx)
|
|
@@ -23,13 +23,26 @@ import shlex
|
|
|
23
23
|
import subprocess
|
|
24
24
|
import tempfile
|
|
25
25
|
from dataclasses import dataclass
|
|
26
|
+
from datetime import datetime
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
|
|
28
|
-
from contree_cli import
|
|
29
|
+
from contree_cli import (
|
|
30
|
+
CLIENT,
|
|
31
|
+
FORMATTER,
|
|
32
|
+
SESSION_STORE,
|
|
33
|
+
ArgumentsProtocol,
|
|
34
|
+
SetupResult,
|
|
35
|
+
)
|
|
29
36
|
from contree_cli.client import ApiError, ContreeClient, resolve_image, stream_response
|
|
30
37
|
from contree_cli.config import EDITOR
|
|
31
38
|
from contree_cli.session import SessionStore
|
|
32
|
-
from contree_cli.types import
|
|
39
|
+
from contree_cli.types import (
|
|
40
|
+
FLAGS,
|
|
41
|
+
isoformat_datetime,
|
|
42
|
+
parse_datetime,
|
|
43
|
+
parse_interval,
|
|
44
|
+
positive_int,
|
|
45
|
+
)
|
|
33
46
|
|
|
34
47
|
logger = logging.getLogger(__name__)
|
|
35
48
|
|
|
@@ -61,6 +74,27 @@ class FileCpArgs(ArgumentsProtocol):
|
|
|
61
74
|
return cls(src=ns.src, dest=ns.dest)
|
|
62
75
|
|
|
63
76
|
|
|
77
|
+
FILE_LIST_LIMIT_DEFAULT = 1000
|
|
78
|
+
FILE_LIST_PAGE_SIZE = 1000
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class FileListArgs(ArgumentsProtocol):
|
|
83
|
+
since: datetime | None = None
|
|
84
|
+
until: datetime | None = None
|
|
85
|
+
limit: int = FILE_LIST_LIMIT_DEFAULT
|
|
86
|
+
quiet: bool = False
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_args(cls, ns: argparse.Namespace) -> FileListArgs:
|
|
90
|
+
return cls(
|
|
91
|
+
since=getattr(ns, "since", None),
|
|
92
|
+
until=getattr(ns, "until", None),
|
|
93
|
+
limit=getattr(ns, "limit", FILE_LIST_LIMIT_DEFAULT),
|
|
94
|
+
quiet=bool(getattr(ns, "quiet", False)),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
64
98
|
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
65
99
|
sub = p.add_subparsers(dest="file_action", required=True)
|
|
66
100
|
edit_p = sub.add_parser(
|
|
@@ -102,6 +136,58 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
|
102
136
|
cp_p.add_argument("dest", help="Destination path inside image")
|
|
103
137
|
cp_p.set_defaults(handler=cmd_file_cp, load_args=FileCpArgs)
|
|
104
138
|
|
|
139
|
+
ls_p = sub.add_parser(
|
|
140
|
+
"ls",
|
|
141
|
+
aliases=["list"],
|
|
142
|
+
help="List uploaded files (joined with local cache)",
|
|
143
|
+
description=(
|
|
144
|
+
"List remote files uploaded to the project and, when present in"
|
|
145
|
+
" the local upload cache, show what produced them under the"
|
|
146
|
+
" 'source' column: either an absolute host path (for run --file"
|
|
147
|
+
" / COPY uploads) or a URL (for ADD URL).\n"
|
|
148
|
+
"\n"
|
|
149
|
+
"source is THIS-MACHINE ONLY: the mapping lives in the local"
|
|
150
|
+
" CLI cache ($CONTREE_HOME/cli/sessions/<profile>.db) and is"
|
|
151
|
+
" never synced. Files uploaded from a different host, by a"
|
|
152
|
+
" teammate, or before tracking landed will show an empty source"
|
|
153
|
+
" -- that is expected, not a bug. Use the remote uuid or sha256"
|
|
154
|
+
" for cross-machine identity."
|
|
155
|
+
),
|
|
156
|
+
epilog=(
|
|
157
|
+
"examples:\n"
|
|
158
|
+
" contree file ls\n"
|
|
159
|
+
" contree file ls --since 1d\n"
|
|
160
|
+
" contree file ls --limit 5000\n"
|
|
161
|
+
" contree file ls -q # uuid + sha256 + source\n"
|
|
162
|
+
" contree -f json file ls\n"
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
ls_p.add_argument(
|
|
166
|
+
*FLAGS["since"],
|
|
167
|
+
type=parse_interval,
|
|
168
|
+
help=parse_interval.__doc__,
|
|
169
|
+
)
|
|
170
|
+
ls_p.add_argument(
|
|
171
|
+
*FLAGS["until"],
|
|
172
|
+
type=parse_interval,
|
|
173
|
+
help="Show files before. " + str(parse_interval.__doc__),
|
|
174
|
+
)
|
|
175
|
+
ls_p.add_argument(
|
|
176
|
+
*FLAGS["limit"],
|
|
177
|
+
type=positive_int,
|
|
178
|
+
default=FILE_LIST_LIMIT_DEFAULT,
|
|
179
|
+
help="Stop after this many files and warn if more are available",
|
|
180
|
+
)
|
|
181
|
+
ls_p.add_argument(
|
|
182
|
+
*FLAGS["quiet"],
|
|
183
|
+
action="store_true",
|
|
184
|
+
help=(
|
|
185
|
+
"Emit only uuid, sha256, and source columns. source is populated"
|
|
186
|
+
" only for files uploaded from this very machine."
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
ls_p.set_defaults(handler=cmd_file_ls, load_args=FileListArgs)
|
|
190
|
+
|
|
105
191
|
return cmd_file_edit, FileEditArgs
|
|
106
192
|
|
|
107
193
|
|
|
@@ -123,7 +209,7 @@ def _upload_and_record(
|
|
|
123
209
|
"""Upload a local file (with dedup) and record as pending."""
|
|
124
210
|
sha = _file_sha256(local_path)
|
|
125
211
|
try:
|
|
126
|
-
resp = client.get("/v1/files
|
|
212
|
+
resp = client.get(f"/v1/files/{sha}")
|
|
127
213
|
file_uuid = json.loads(resp.read())["uuid"]
|
|
128
214
|
logger.info("File already exists on server (%s)", file_uuid)
|
|
129
215
|
except ApiError as exc:
|
|
@@ -222,3 +308,63 @@ def cmd_file_cp(args: FileCpArgs) -> int | None:
|
|
|
222
308
|
title=f"Change file {args.dest}",
|
|
223
309
|
)
|
|
224
310
|
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cmd_file_ls(args: FileListArgs) -> int | None:
|
|
314
|
+
client = CLIENT.get()
|
|
315
|
+
store = SESSION_STORE.get()
|
|
316
|
+
formatter = FORMATTER.get()
|
|
317
|
+
|
|
318
|
+
sources = store.cache.local_file_paths()
|
|
319
|
+
|
|
320
|
+
params: dict[str, str] = {}
|
|
321
|
+
if args.since is not None:
|
|
322
|
+
params["since"] = isoformat_datetime(args.since)
|
|
323
|
+
if args.until is not None:
|
|
324
|
+
params["until"] = isoformat_datetime(args.until)
|
|
325
|
+
|
|
326
|
+
offset = 0
|
|
327
|
+
emitted = 0
|
|
328
|
+
while emitted < args.limit:
|
|
329
|
+
page_size = min(FILE_LIST_PAGE_SIZE, args.limit - emitted)
|
|
330
|
+
page = {**params, "offset": str(offset), "limit": str(page_size)}
|
|
331
|
+
resp = client.get("/v1/files", params=page)
|
|
332
|
+
data = json.loads(resp.read())
|
|
333
|
+
files = data.get("files", [])
|
|
334
|
+
if not files:
|
|
335
|
+
return None
|
|
336
|
+
for entry in files:
|
|
337
|
+
uuid_str = entry.get("uuid")
|
|
338
|
+
source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else ""
|
|
339
|
+
if args.quiet:
|
|
340
|
+
formatter(
|
|
341
|
+
uuid=uuid_str,
|
|
342
|
+
sha256=entry.get("sha256", ""),
|
|
343
|
+
source=source,
|
|
344
|
+
)
|
|
345
|
+
continue
|
|
346
|
+
row: dict[str, object] = {}
|
|
347
|
+
for key, value in entry.items():
|
|
348
|
+
if isinstance(value, (dict, list)):
|
|
349
|
+
continue
|
|
350
|
+
if key in {"created_at", "updated_at"} and isinstance(value, str):
|
|
351
|
+
value = parse_datetime(value)
|
|
352
|
+
row[key] = value
|
|
353
|
+
row["source"] = source
|
|
354
|
+
formatter(**row)
|
|
355
|
+
emitted += len(files)
|
|
356
|
+
if len(files) < page_size:
|
|
357
|
+
return None
|
|
358
|
+
offset += len(files)
|
|
359
|
+
|
|
360
|
+
probe = {**params, "offset": str(offset), "limit": "1"}
|
|
361
|
+
resp = client.get("/v1/files", params=probe)
|
|
362
|
+
data = json.loads(resp.read())
|
|
363
|
+
if data.get("files"):
|
|
364
|
+
formatter.flush()
|
|
365
|
+
logger.warning(
|
|
366
|
+
"Output truncated at --limit=%d files; more results are"
|
|
367
|
+
" available. Raise --limit or narrow with --since/--until.",
|
|
368
|
+
args.limit,
|
|
369
|
+
)
|
|
370
|
+
return None
|
|
@@ -202,7 +202,8 @@ def _add_list_args(p: argparse.ArgumentParser) -> None:
|
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
205
|
-
# Parent-level args
|
|
205
|
+
# Parent-level list args mirror the subcommand so `contree images
|
|
206
|
+
# --prefix …` works without typing `list`.
|
|
206
207
|
_add_list_args(p)
|
|
207
208
|
|
|
208
209
|
sub = p.add_subparsers(dest="images_action")
|
|
@@ -286,12 +287,16 @@ def cmd_images(args: ImagesArgs) -> None:
|
|
|
286
287
|
if not images:
|
|
287
288
|
return
|
|
288
289
|
for image in images:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
290
|
+
row: dict[str, object] = {}
|
|
291
|
+
for key, value in image.items():
|
|
292
|
+
if isinstance(value, (dict, list)):
|
|
293
|
+
continue
|
|
294
|
+
if key == "created_at" and isinstance(value, str):
|
|
295
|
+
value = parse_datetime(value)
|
|
296
|
+
if key == "tag" and value is None:
|
|
297
|
+
value = ""
|
|
298
|
+
row[key] = value
|
|
299
|
+
formatter(**row)
|
|
295
300
|
emitted += len(images)
|
|
296
301
|
if len(images) < page_size:
|
|
297
302
|
return
|