@jenga-ai/agent 1.1.0 → 1.1.1
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/README.md +7 -3
- package/agents/developer.md +82 -2
- package/agents/scrum-master.md +140 -21
- package/agents/tester.md +90 -8
- package/hooks/on_session_end.sh +171 -20
- package/package.json +1 -1
- package/scripts/check-permission-level.sh +107 -0
- package/scripts/check-publicignore-match.sh +122 -0
- package/scripts/check-worktree-liveness.sh +193 -0
- package/scripts/generate-rapport-manifest.sh +43 -0
- package/scripts/idea_manager.sh +47 -0
- package/scripts/install-worktree-commit-guard.sh +134 -0
- package/scripts/jenga-permission-level-switch.sh +109 -0
- package/scripts/smoke-harness.sh +139 -0
- package/scripts/validate-board.sh +62 -0
- package/scripts/with-lock.sh +158 -0
- package/scripts/worktree-remove-guard.sh +204 -0
- package/skills/clearify/SKILL.md +52 -0
- package/skills/commit/SKILL.md +13 -4
- package/skills/distribute/CONFIG_SCHEMA.md +60 -2
- package/skills/do/SKILL.md +48 -11
- package/skills/doc-sync/SKILL.md +16 -0
- package/skills/doc-sync/assets/doc_targets.md +11 -0
- package/skills/idea/SKILL.md +56 -0
- package/skills/idea/assets/idea_handoff_template.md +26 -0
- package/skills/idea/assets/idea_template.md +3 -0
- package/skills/init/SKILL.md +100 -7
- package/skills/init/assets/directory_structure.txt +1 -0
- package/skills/init/assets/workflow_template.json +1 -1
- package/skills/init/scripts/apply-project-visibility.sh +176 -0
- package/skills/init/scripts/detect-existing-codebase.sh +166 -0
- package/skills/init/scripts/init.sh +30 -1
- package/skills/jenga/SKILL.md +160 -17
- package/skills/jenga/scripts/board-scan.sh +238 -0
- package/skills/jenga/scripts/cascade-resolve.sh +297 -0
- package/skills/jenga/scripts/render-confirmation.sh +679 -0
- package/skills/jenga/scripts/render-picker.sh +439 -0
- package/skills/jenga/scripts/resolve-id.sh +367 -0
- package/skills/jenga-permission-level/SKILL.md +81 -0
- package/skills/proceed/SKILL.md +1 -1
- package/skills/publish/SKILL.md +8 -5
- package/skills/publish/assets/ci-contract.md +2 -2
- package/skills/publish/assets/ownership-matrix.md +1 -1
- package/skills/publish/scripts/finalize_changelog.sh +115 -0
- package/skills/publish/scripts/generate_release_notes.sh +475 -28
- package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
- package/skills/publish/scripts/publish_deploy.sh +38 -8
- package/skills/publish/scripts/run_gates.sh +2 -2
- package/skills/reconcile/SKILL.md +117 -5
- package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
- package/skills/spinoff/SKILL.md +12 -7
- package/skills/todo/SKILL.md +2 -0
- package/skills/uncharted/SKILL.md +711 -0
- package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
- package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
- package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
- package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
- package/skills/uncharted/scripts/detect-tests.sh +553 -0
- package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
- package/skills/uncharted/scripts/enumerate-target.sh +470 -0
- package/skills/uncharted/scripts/import-source.sh +517 -0
- package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
- package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
- package/skills/uncharted/scripts/run-engine.sh +655 -0
- package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
- package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
- package/skills/wtf/SKILL.md +20 -0
- package/templates/CHANGELOG_TEMPLATE.md +13 -0
- package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +157 -10
- package/templates/permission-levels/README.md +73 -0
- package/templates/permission-levels/level-1-locked.json +71 -0
- package/templates/permission-levels/level-2-guarded.json +64 -0
- package/templates/permission-levels/level-3-standard.json +62 -0
- package/templates/permission-levels/level-4-elevated.json +60 -0
- package/templates/permission-levels/level-5-unrestricted.json +58 -0
- package/skills/convert/SKILL.md +0 -124
- package/skills/convert/convert_cli.py +0 -235
- package/skills/convert/tests/sample.csv +0 -4
- package/skills/convert/tests/sample.json +0 -5
- package/skills/convert/tests/sample.jsonl +0 -3
- package/skills/convert/tests/sample.yaml +0 -18
- package/skills/convert/tests/sample_obj.csv +0 -2
- package/skills/convert/tests/sample_obj.json +0 -9
- package/skills/mirror-public/SKILL.md +0 -237
- package/skills/mirror-public/assets/config.json +0 -5
- package/skills/mirror-public/scripts/mirror.sh +0 -374
- package/skills/self-sync/SKILL.md +0 -73
- package/skills/self-sync/scripts/run.js +0 -136
- package/skills/strategy/SKILL.md +0 -312
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# skills/uncharted/scripts/import-source.sh
|
|
4
|
+
#
|
|
5
|
+
# Acquisition half of `/uncharted import`. Fetches an external source into a
|
|
6
|
+
# STAGING directory only — it never places anything at a final destination
|
|
7
|
+
# inside the repo, because placement requires user confirmation (E40_S03_T02)
|
|
8
|
+
# and the analysis handoff belongs to `segment` mode (E40_S03_T03).
|
|
9
|
+
#
|
|
10
|
+
# Three source types, auto-detected from the argument (override with --type):
|
|
11
|
+
# git shallow clone (--depth 1) of a git URL
|
|
12
|
+
# path recursive copy of a local directory or file OUTSIDE this repo
|
|
13
|
+
# snippet pasted text read from stdin, written under a caller-supplied name
|
|
14
|
+
#
|
|
15
|
+
# SECURITY CONTRACT — the acquired source is treated as hostile:
|
|
16
|
+
# * Nothing from the source is ever executed, sourced, built, or installed.
|
|
17
|
+
# No install scripts, no postinstall hooks, no makefiles, no git hooks.
|
|
18
|
+
# * Clones run with core.hooksPath=/dev/null and GIT_TERMINAL_PROMPT=0, with
|
|
19
|
+
# submodule recursion off, and the cloned .git directory is deleted after
|
|
20
|
+
# the clone so no hooks or remote config survive into staging.
|
|
21
|
+
# * The staging root lives under the system temp dir, OUTSIDE the repo
|
|
22
|
+
# working tree, so acquired source can never be committed by accident.
|
|
23
|
+
# * Source arguments are validated against path traversal and against being
|
|
24
|
+
# reinterpreted as command options.
|
|
25
|
+
#
|
|
26
|
+
# Usage:
|
|
27
|
+
# import-source.sh [options] <source>
|
|
28
|
+
# cat file | import-source.sh [options] --type snippet --name <filename> -
|
|
29
|
+
#
|
|
30
|
+
# Options:
|
|
31
|
+
# --type git|path|snippet Force the source type (default: auto-detect)
|
|
32
|
+
# --name <filename> Filename for snippet mode (default: snippet.txt).
|
|
33
|
+
# Must be a bare filename — no directory separators.
|
|
34
|
+
# --staging-dir <dir> Use this staging root instead of a fresh temp dir.
|
|
35
|
+
# Must not exist, or must be an empty directory.
|
|
36
|
+
# --json-only Print only the JSON summary on stdout.
|
|
37
|
+
# -h, --help Show this help.
|
|
38
|
+
#
|
|
39
|
+
# Output (stdout):
|
|
40
|
+
# <staging path> (suppressed by --json-only)
|
|
41
|
+
# <JSON summary object>
|
|
42
|
+
#
|
|
43
|
+
# JSON summary fields:
|
|
44
|
+
# source_type, source, staging_path, content_path,
|
|
45
|
+
# file_count, total_size_bytes, total_size_human, symlink_count, git_ref
|
|
46
|
+
#
|
|
47
|
+
# Exit codes:
|
|
48
|
+
# 0 source acquired and staged
|
|
49
|
+
# 1 usage error (bad flag, missing argument, invalid --name/--type)
|
|
50
|
+
# 2 environment error (git unavailable, cannot create staging dir)
|
|
51
|
+
# 3 git clone failed (network, auth, bad URL, missing ref)
|
|
52
|
+
# 4 source unreadable, missing, empty, or refused (in-repo path, traversal)
|
|
53
|
+
# 5 empty snippet input
|
|
54
|
+
# 6 staging directory refused (non-empty, not a directory, or inside the repo)
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
set -euo pipefail
|
|
58
|
+
|
|
59
|
+
SELF="$(basename "$0")"
|
|
60
|
+
|
|
61
|
+
die() {
|
|
62
|
+
local code="$1"; shift
|
|
63
|
+
printf '%s: error: %s\n' "$SELF" "$*" >&2
|
|
64
|
+
exit "$code"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
usage() {
|
|
68
|
+
sed -n '/^# Usage:/,/^# Exit codes:/p' "$0" | sed 's/^# \{0,1\}//' >&2
|
|
69
|
+
exit 1
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Argument parsing
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
SOURCE=""
|
|
77
|
+
FORCED_TYPE=""
|
|
78
|
+
SNIPPET_NAME="snippet.txt"
|
|
79
|
+
STAGING_ROOT=""
|
|
80
|
+
JSON_ONLY=0
|
|
81
|
+
SOURCE_SET=0
|
|
82
|
+
|
|
83
|
+
while [ "$#" -gt 0 ]; do
|
|
84
|
+
case "$1" in
|
|
85
|
+
--type)
|
|
86
|
+
[ "$#" -ge 2 ] || die 1 "--type requires a value (git|path|snippet)"
|
|
87
|
+
FORCED_TYPE="$2"; shift 2 ;;
|
|
88
|
+
--type=*)
|
|
89
|
+
FORCED_TYPE="${1#--type=}"; shift ;;
|
|
90
|
+
--name)
|
|
91
|
+
[ "$#" -ge 2 ] || die 1 "--name requires a value"
|
|
92
|
+
SNIPPET_NAME="$2"; shift 2 ;;
|
|
93
|
+
--name=*)
|
|
94
|
+
SNIPPET_NAME="${1#--name=}"; shift ;;
|
|
95
|
+
--staging-dir)
|
|
96
|
+
[ "$#" -ge 2 ] || die 1 "--staging-dir requires a value"
|
|
97
|
+
STAGING_ROOT="$2"; shift 2 ;;
|
|
98
|
+
--staging-dir=*)
|
|
99
|
+
STAGING_ROOT="${1#--staging-dir=}"; shift ;;
|
|
100
|
+
--json-only)
|
|
101
|
+
JSON_ONLY=1; shift ;;
|
|
102
|
+
-h|--help)
|
|
103
|
+
usage ;;
|
|
104
|
+
--)
|
|
105
|
+
shift
|
|
106
|
+
if [ "$#" -gt 0 ]; then SOURCE="$1"; SOURCE_SET=1; shift; fi
|
|
107
|
+
[ "$#" -eq 0 ] || die 1 "unexpected extra argument: $1" ;;
|
|
108
|
+
-)
|
|
109
|
+
SOURCE="-"; SOURCE_SET=1; shift ;;
|
|
110
|
+
-*)
|
|
111
|
+
die 1 "unknown option: $1 (use -- before a source that starts with '-')" ;;
|
|
112
|
+
*)
|
|
113
|
+
[ "$SOURCE_SET" -eq 0 ] || die 1 "unexpected extra argument: $1"
|
|
114
|
+
SOURCE="$1"; SOURCE_SET=1; shift ;;
|
|
115
|
+
esac
|
|
116
|
+
done
|
|
117
|
+
|
|
118
|
+
[ "$SOURCE_SET" -eq 1 ] || usage
|
|
119
|
+
|
|
120
|
+
if [ -n "$FORCED_TYPE" ]; then
|
|
121
|
+
case "$FORCED_TYPE" in
|
|
122
|
+
git|path|snippet) ;;
|
|
123
|
+
*) die 1 "invalid --type '$FORCED_TYPE' (expected git, path, or snippet)" ;;
|
|
124
|
+
esac
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# --name must be a bare filename: no separators, no traversal, no leading dash.
|
|
128
|
+
case "$SNIPPET_NAME" in
|
|
129
|
+
""|.|..) die 1 "invalid --name '$SNIPPET_NAME'" ;;
|
|
130
|
+
*/*|*\\*) die 1 "invalid --name '$SNIPPET_NAME': must not contain a path separator" ;;
|
|
131
|
+
-*) die 1 "invalid --name '$SNIPPET_NAME': must not start with '-'" ;;
|
|
132
|
+
esac
|
|
133
|
+
case "$SNIPPET_NAME" in
|
|
134
|
+
*..*) die 1 "invalid --name '$SNIPPET_NAME': must not contain '..'" ;;
|
|
135
|
+
esac
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Helpers
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
# Canonicalise a path without depending on GNU realpath (absent on macOS).
|
|
142
|
+
# Collapses symlinks and '..' components so traversal cannot be smuggled past
|
|
143
|
+
# the in-repo check below. Works for a path whose parent exists.
|
|
144
|
+
canonicalise() {
|
|
145
|
+
local target="$1" dir base link hops=0
|
|
146
|
+
# Resolve a symlinked FINAL component before canonicalising. The [ -d ]
|
|
147
|
+
# branch below already follows directory symlinks via `cd`, but a symlink to
|
|
148
|
+
# a *file* would otherwise be reported at its own location — letting a link
|
|
149
|
+
# that points at an in-repo file slip past is_inside_repo while `cp`
|
|
150
|
+
# dereferences it. The hop cap stops a symlink loop from hanging the script.
|
|
151
|
+
while [ -L "$target" ] && [ "$hops" -lt 40 ]; do
|
|
152
|
+
link="$(readlink "$target")" || break
|
|
153
|
+
case "$link" in
|
|
154
|
+
/*) target="$link" ;;
|
|
155
|
+
*) target="$(dirname "$target")/$link" ;;
|
|
156
|
+
esac
|
|
157
|
+
hops=$((hops + 1))
|
|
158
|
+
done
|
|
159
|
+
if [ -d "$target" ]; then
|
|
160
|
+
(cd "$target" 2>/dev/null && pwd -P)
|
|
161
|
+
else
|
|
162
|
+
dir="$(dirname "$target")"
|
|
163
|
+
base="$(basename "$target")"
|
|
164
|
+
if [ -d "$dir" ]; then
|
|
165
|
+
printf '%s/%s\n' "$(cd "$dir" && pwd -P)" "$base"
|
|
166
|
+
else
|
|
167
|
+
printf '%s\n' "$target"
|
|
168
|
+
fi
|
|
169
|
+
fi
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Echo the working-tree root containing $1, if any. Asks git about the
|
|
173
|
+
# CANDIDATE PATH rather than matching against a precomputed list of roots —
|
|
174
|
+
# a list can never cover every working tree on the host, and a lexical prefix
|
|
175
|
+
# match in particular misses the parent repo when this script runs from a git
|
|
176
|
+
# worktree (the main checkout is an ancestor of .claude/worktrees/..., so it
|
|
177
|
+
# never matches). Walks up to the nearest existing ancestor so it also answers
|
|
178
|
+
# for a staging directory that has not been created yet.
|
|
179
|
+
path_repo_root() {
|
|
180
|
+
local probe="$1"
|
|
181
|
+
while [ -n "$probe" ] && [ "$probe" != "/" ] && [ ! -d "$probe" ]; do
|
|
182
|
+
probe="$(dirname "$probe")"
|
|
183
|
+
done
|
|
184
|
+
[ -d "$probe" ] || return 1
|
|
185
|
+
git -C "$probe" rev-parse --show-toplevel 2>/dev/null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# True when $1 sits inside ANY git working tree; echoes that tree's root.
|
|
189
|
+
is_inside_repo() {
|
|
190
|
+
local root
|
|
191
|
+
root="$(path_repo_root "$1")" || return 1
|
|
192
|
+
[ -n "$root" ] || return 1
|
|
193
|
+
printf '%s\n' "$root"
|
|
194
|
+
return 0
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Working-tree roots belonging to THIS project's repository. A git worktree and
|
|
198
|
+
# its main checkout share one repository, so `git worktree list` enumerates all
|
|
199
|
+
# of them from either side — which is what the parent-of-worktree case needs and
|
|
200
|
+
# what a lexical prefix match over two guessed roots got wrong.
|
|
201
|
+
own_worktree_roots() {
|
|
202
|
+
local script_dir d
|
|
203
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)" || script_dir=""
|
|
204
|
+
for d in "$script_dir" "$(pwd -P)"; do
|
|
205
|
+
[ -n "$d" ] || continue
|
|
206
|
+
git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 || continue
|
|
207
|
+
git -C "$d" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p'
|
|
208
|
+
done | sort -u
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# True when $1 is inside one of THIS project's working trees; echoes that root.
|
|
212
|
+
# Deliberately NOT "inside any git repository": an external cloned project is a
|
|
213
|
+
# git repo too, and importing one is the entire point of `import` mode. Only
|
|
214
|
+
# staging uses the broader any-repo test, because writing acquired source into
|
|
215
|
+
# any committable tree is wrong regardless of whose tree it is.
|
|
216
|
+
is_inside_own_repo() {
|
|
217
|
+
local candidate="$1" root real
|
|
218
|
+
while IFS= read -r root; do
|
|
219
|
+
[ -n "$root" ] || continue
|
|
220
|
+
real="$(cd "$root" 2>/dev/null && pwd -P)" || continue
|
|
221
|
+
if [ "$candidate" = "$real" ]; then printf '%s\n' "$real"; return 0; fi
|
|
222
|
+
case "$candidate" in "$real"/*) printf '%s\n' "$real"; return 0 ;; esac
|
|
223
|
+
done <<< "$OWN_WORKTREE_ROOTS"
|
|
224
|
+
return 1
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Detect the source type from the argument alone (pure; no filesystem writes).
|
|
228
|
+
detect_type() {
|
|
229
|
+
local src="$1"
|
|
230
|
+
if [ "$src" = "-" ]; then
|
|
231
|
+
printf 'snippet\n'; return 0
|
|
232
|
+
fi
|
|
233
|
+
case "$src" in
|
|
234
|
+
https://*|http://*|git://*|ssh://*|git+ssh://*|file://*)
|
|
235
|
+
printf 'git\n'; return 0 ;;
|
|
236
|
+
*@*:*)
|
|
237
|
+
# scp-style git remote, e.g. git@github.com:owner/repo.git
|
|
238
|
+
printf 'git\n'; return 0 ;;
|
|
239
|
+
*.git)
|
|
240
|
+
printf 'git\n'; return 0 ;;
|
|
241
|
+
esac
|
|
242
|
+
printf 'path\n'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
human_size() {
|
|
246
|
+
local bytes="$1"
|
|
247
|
+
if [ "$bytes" -lt 1024 ]; then
|
|
248
|
+
printf '%sB\n' "$bytes"
|
|
249
|
+
elif [ "$bytes" -lt 1048576 ]; then
|
|
250
|
+
printf '%sKB\n' "$(( (bytes + 512) / 1024 ))"
|
|
251
|
+
elif [ "$bytes" -lt 1073741824 ]; then
|
|
252
|
+
printf '%sMB\n' "$(( (bytes + 524288) / 1048576 ))"
|
|
253
|
+
else
|
|
254
|
+
printf '%sGB\n' "$(( (bytes + 536870912) / 1073741824 ))"
|
|
255
|
+
fi
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
json_escape() {
|
|
259
|
+
# Minimal RFC 8259 string escaping for the jq-less fallback path.
|
|
260
|
+
printf '%s' "$1" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
|
|
261
|
+
-e 's/\t/\\t/g' -e 's/\r/\\r/g' | awk 'BEGIN{ORS=""} {print sep $0; sep="\\n"}'
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Resolve type
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
if [ -n "$FORCED_TYPE" ]; then
|
|
269
|
+
SOURCE_TYPE="$FORCED_TYPE"
|
|
270
|
+
else
|
|
271
|
+
SOURCE_TYPE="$(detect_type "$SOURCE")"
|
|
272
|
+
fi
|
|
273
|
+
|
|
274
|
+
OWN_WORKTREE_ROOTS="$(own_worktree_roots)"
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Pre-flight validation (before creating any staging directory)
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
case "$SOURCE_TYPE" in
|
|
281
|
+
git)
|
|
282
|
+
command -v git >/dev/null 2>&1 || die 2 "git is required for git sources but was not found on PATH"
|
|
283
|
+
case "$SOURCE" in
|
|
284
|
+
-*) die 4 "refusing git URL '$SOURCE': must not start with '-'" ;;
|
|
285
|
+
esac
|
|
286
|
+
# Reject shell/option metacharacters that have no place in a git URL.
|
|
287
|
+
# shellcheck disable=SC2016 # '$(' is an intentional literal, not an expansion
|
|
288
|
+
case "$SOURCE" in
|
|
289
|
+
*';'*|*'|'*|*'&'*|*'$('*|*'`'*|*$'\n'*|*$'\r'*)
|
|
290
|
+
die 4 "refusing git URL '$SOURCE': contains disallowed characters" ;;
|
|
291
|
+
esac
|
|
292
|
+
;;
|
|
293
|
+
path)
|
|
294
|
+
[ -e "$SOURCE" ] || die 4 "source path does not exist: $SOURCE"
|
|
295
|
+
[ -r "$SOURCE" ] || die 4 "source path is not readable: $SOURCE"
|
|
296
|
+
ABS_SOURCE="$(canonicalise "$SOURCE")"
|
|
297
|
+
case "$ABS_SOURCE" in
|
|
298
|
+
/*) ;;
|
|
299
|
+
*) die 4 "could not resolve source path to an absolute location: $SOURCE" ;;
|
|
300
|
+
esac
|
|
301
|
+
# This script imports EXTERNAL sources. An in-repo target is `segment`
|
|
302
|
+
# mode's job, and allowing it here would let a traversal argument
|
|
303
|
+
# ('../../repo/x') round-trip repo content through the import path.
|
|
304
|
+
if MATCHED_ROOT="$(is_inside_own_repo "$ABS_SOURCE")"; then
|
|
305
|
+
die 4 "refusing '$SOURCE': resolves to $ABS_SOURCE, inside this project's repo at $MATCHED_ROOT. Use \`/uncharted segment\` for in-repo targets."
|
|
306
|
+
fi
|
|
307
|
+
if [ -d "$ABS_SOURCE" ] && [ -z "$(ls -A "$ABS_SOURCE" 2>/dev/null)" ]; then
|
|
308
|
+
die 4 "source directory is empty: $SOURCE"
|
|
309
|
+
fi
|
|
310
|
+
if [ -f "$ABS_SOURCE" ] && [ ! -s "$ABS_SOURCE" ]; then
|
|
311
|
+
die 4 "source file is empty: $SOURCE"
|
|
312
|
+
fi
|
|
313
|
+
;;
|
|
314
|
+
snippet)
|
|
315
|
+
: # stdin is validated at read time
|
|
316
|
+
;;
|
|
317
|
+
esac
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Staging directory
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
if [ -n "$STAGING_ROOT" ]; then
|
|
324
|
+
# Staging must never live inside the repo working tree — that is the
|
|
325
|
+
# structural guarantee that acquired external source cannot be committed.
|
|
326
|
+
STAGING_PROBE="$(canonicalise "$STAGING_ROOT")"
|
|
327
|
+
if MATCHED_ROOT="$(is_inside_repo "$STAGING_PROBE")"; then
|
|
328
|
+
die 6 "refusing --staging-dir '$STAGING_ROOT': staging must live outside the repo working tree ($MATCHED_ROOT)"
|
|
329
|
+
fi
|
|
330
|
+
if [ -e "$STAGING_ROOT" ]; then
|
|
331
|
+
[ -d "$STAGING_ROOT" ] || die 6 "staging path exists and is not a directory: $STAGING_ROOT"
|
|
332
|
+
[ -z "$(ls -A "$STAGING_ROOT" 2>/dev/null)" ] || die 6 "refusing to write into non-empty staging directory: $STAGING_ROOT"
|
|
333
|
+
else
|
|
334
|
+
mkdir -p "$STAGING_ROOT" || die 2 "could not create staging directory: $STAGING_ROOT"
|
|
335
|
+
fi
|
|
336
|
+
STAGING_ROOT="$(canonicalise "$STAGING_ROOT")"
|
|
337
|
+
else
|
|
338
|
+
STAGING_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/uncharted-import.XXXXXXXX")" \
|
|
339
|
+
|| die 2 "could not create a temporary staging directory"
|
|
340
|
+
STAGING_ROOT="$(canonicalise "$STAGING_ROOT")"
|
|
341
|
+
# The default path is only safe if TMPDIR itself is not inside a working
|
|
342
|
+
# tree. Exotic, but the guarantee above is stated absolutely, so assert it
|
|
343
|
+
# rather than assume it — and clean up the temp dir we just made.
|
|
344
|
+
if MATCHED_ROOT="$(is_inside_repo "$STAGING_ROOT")"; then
|
|
345
|
+
rmdir "$STAGING_ROOT" 2>/dev/null || true
|
|
346
|
+
die 6 "refusing default staging dir '$STAGING_ROOT': TMPDIR resolves inside a repo working tree ($MATCHED_ROOT). Set TMPDIR or pass --staging-dir to a location outside any repo."
|
|
347
|
+
fi
|
|
348
|
+
fi
|
|
349
|
+
|
|
350
|
+
# Acquired content never sits at the staging root itself — downstream steps
|
|
351
|
+
# need a stable subdirectory to point the placement prompt at.
|
|
352
|
+
CONTENT_DIR="$STAGING_ROOT/source"
|
|
353
|
+
[ ! -e "$CONTENT_DIR" ] || die 6 "refusing to write into non-empty staging directory: $STAGING_ROOT"
|
|
354
|
+
|
|
355
|
+
GIT_REF=""
|
|
356
|
+
|
|
357
|
+
# shellcheck disable=SC2329 # invoked via `trap`, not called directly
|
|
358
|
+
cleanup_on_failure() {
|
|
359
|
+
local code="$?"
|
|
360
|
+
if [ "$code" -ne 0 ] && [ -n "${STAGING_ROOT:-}" ] && [ -d "$STAGING_ROOT" ]; then
|
|
361
|
+
case "$STAGING_ROOT" in
|
|
362
|
+
/|/tmp|/var|/usr|/etc|"$HOME") : ;; # never blast a system root
|
|
363
|
+
*)
|
|
364
|
+
if is_inside_repo "$STAGING_ROOT" >/dev/null; then
|
|
365
|
+
: # never rm -rf anything inside a repo working tree
|
|
366
|
+
else
|
|
367
|
+
rm -rf "$STAGING_ROOT"
|
|
368
|
+
fi ;;
|
|
369
|
+
esac
|
|
370
|
+
fi
|
|
371
|
+
}
|
|
372
|
+
trap cleanup_on_failure EXIT
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# Acquisition
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
case "$SOURCE_TYPE" in
|
|
379
|
+
git)
|
|
380
|
+
CLONE_LOG="$STAGING_ROOT/.clone.log"
|
|
381
|
+
# Hardened clone: no hooks, no interactive credential prompt, no tags,
|
|
382
|
+
# no submodule recursion, single branch, depth 1.
|
|
383
|
+
if ! GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/usr/bin/true \
|
|
384
|
+
git -c core.hooksPath=/dev/null \
|
|
385
|
+
-c protocol.ext.allow=never \
|
|
386
|
+
-c advice.detachedHead=false \
|
|
387
|
+
clone --depth 1 --no-tags --single-branch --no-recurse-submodules \
|
|
388
|
+
-- "$SOURCE" "$CONTENT_DIR" >"$CLONE_LOG" 2>&1; then
|
|
389
|
+
printf '%s: error: git clone failed for %s\n' "$SELF" "$SOURCE" >&2
|
|
390
|
+
printf -- '--- git output ---\n' >&2
|
|
391
|
+
cat "$CLONE_LOG" >&2 || true
|
|
392
|
+
printf -- '------------------\n' >&2
|
|
393
|
+
printf 'Hint: for a private repository this is usually missing or expired credentials.\n' >&2
|
|
394
|
+
printf ' Interactive prompts are disabled on purpose; configure git credentials and retry.\n' >&2
|
|
395
|
+
exit 3
|
|
396
|
+
fi
|
|
397
|
+
if [ -d "$CONTENT_DIR/.git" ]; then
|
|
398
|
+
GIT_REF="$(git -C "$CONTENT_DIR" rev-parse --short HEAD 2>/dev/null || true)"
|
|
399
|
+
# Drop .git entirely: removes fetched hooks/config and prevents the
|
|
400
|
+
# staged tree from behaving as a nested repository downstream.
|
|
401
|
+
rm -rf "$CONTENT_DIR/.git"
|
|
402
|
+
fi
|
|
403
|
+
rm -f "$CLONE_LOG"
|
|
404
|
+
;;
|
|
405
|
+
|
|
406
|
+
path)
|
|
407
|
+
mkdir -p "$CONTENT_DIR" || die 2 "could not create staging content directory: $CONTENT_DIR"
|
|
408
|
+
if [ -d "$ABS_SOURCE" ]; then
|
|
409
|
+
# Copy the directory's CONTENTS (not the directory itself) into staging.
|
|
410
|
+
# `cp -R src/. dest/` is the portable form that includes dotfiles.
|
|
411
|
+
cp -R "$ABS_SOURCE/." "$CONTENT_DIR/" || die 4 "failed to copy source directory: $SOURCE"
|
|
412
|
+
else
|
|
413
|
+
cp "$ABS_SOURCE" "$CONTENT_DIR/$(basename "$ABS_SOURCE")" || die 4 "failed to copy source file: $SOURCE"
|
|
414
|
+
fi
|
|
415
|
+
# A copied tree may carry its own .git; strip it for the same reasons as above.
|
|
416
|
+
rm -rf "$CONTENT_DIR/.git"
|
|
417
|
+
;;
|
|
418
|
+
|
|
419
|
+
snippet)
|
|
420
|
+
mkdir -p "$CONTENT_DIR" || die 2 "could not create staging content directory: $CONTENT_DIR"
|
|
421
|
+
SNIPPET_FILE="$CONTENT_DIR/$SNIPPET_NAME"
|
|
422
|
+
if [ "$SOURCE" = "-" ] || [ "$SOURCE" = "" ]; then
|
|
423
|
+
cat > "$SNIPPET_FILE" || die 4 "failed to read snippet from stdin"
|
|
424
|
+
elif [ -f "$SOURCE" ]; then
|
|
425
|
+
[ -r "$SOURCE" ] || die 4 "snippet file is not readable: $SOURCE"
|
|
426
|
+
cat -- "$SOURCE" > "$SNIPPET_FILE" || die 4 "failed to read snippet file: $SOURCE"
|
|
427
|
+
else
|
|
428
|
+
printf '%s' "$SOURCE" > "$SNIPPET_FILE" || die 4 "failed to write snippet"
|
|
429
|
+
fi
|
|
430
|
+
if [ ! -s "$SNIPPET_FILE" ]; then
|
|
431
|
+
die 5 "empty snippet input — nothing to import"
|
|
432
|
+
fi
|
|
433
|
+
;;
|
|
434
|
+
esac
|
|
435
|
+
|
|
436
|
+
if [ ! -d "$CONTENT_DIR" ] || [ -z "$(ls -A "$CONTENT_DIR" 2>/dev/null)" ]; then
|
|
437
|
+
die 4 "acquisition produced no content for source: $SOURCE"
|
|
438
|
+
fi
|
|
439
|
+
|
|
440
|
+
# Nothing in the staged tree should be executable — this script never runs it,
|
|
441
|
+
# and a non-executable tree makes accidental execution downstream harder.
|
|
442
|
+
find "$CONTENT_DIR" -type f -exec chmod a-x {} + 2>/dev/null || true
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# Summary
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
FILE_COUNT="$(find "$CONTENT_DIR" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
|
449
|
+
|
|
450
|
+
# Apparent size, not disk usage: `du` rounds every file up to a block, which
|
|
451
|
+
# reports 4KB for a 30-byte snippet. BSD and GNU `stat` disagree on flags, so
|
|
452
|
+
# resolve the right one once and batch with `find -exec ... +`.
|
|
453
|
+
# Probe on `stat --version`, which only GNU coreutils implements. Probing on
|
|
454
|
+
# `stat -f %z` instead is ambiguous: GNU's -f selects *filesystem* status, so
|
|
455
|
+
# it can exit 0 on Linux and wrongly select the BSD branch.
|
|
456
|
+
if stat --version >/dev/null 2>&1; then
|
|
457
|
+
STAT_ARGS=(-c %s) # GNU / coreutils
|
|
458
|
+
else
|
|
459
|
+
STAT_ARGS=(-f %z) # BSD / macOS
|
|
460
|
+
fi
|
|
461
|
+
TOTAL_BYTES="$(find "$CONTENT_DIR" -type f -exec stat "${STAT_ARGS[@]}" {} + 2>/dev/null \
|
|
462
|
+
| awk '{ s += $1 } END { print s + 0 }')"
|
|
463
|
+
[ -n "$TOTAL_BYTES" ] || TOTAL_BYTES=0
|
|
464
|
+
TOTAL_HUMAN="$(human_size "$TOTAL_BYTES")"
|
|
465
|
+
|
|
466
|
+
# Symlinks are preserved rather than dereferenced or stripped — deleting them
|
|
467
|
+
# would corrupt legitimate source trees. But a symlink escaping the staged tree
|
|
468
|
+
# would let the placement step (E40_S03_T02) pull arbitrary host files into the
|
|
469
|
+
# repo, so surface the count and let placement decide.
|
|
470
|
+
SYMLINK_COUNT="$(find "$CONTENT_DIR" -type l 2>/dev/null | wc -l | tr -d ' ')"
|
|
471
|
+
[ -n "$SYMLINK_COUNT" ] || SYMLINK_COUNT=0
|
|
472
|
+
|
|
473
|
+
trap - EXIT
|
|
474
|
+
|
|
475
|
+
if [ "$JSON_ONLY" -eq 0 ]; then
|
|
476
|
+
printf '%s\n' "$STAGING_ROOT"
|
|
477
|
+
fi
|
|
478
|
+
|
|
479
|
+
if command -v jq >/dev/null 2>&1; then
|
|
480
|
+
jq -n \
|
|
481
|
+
--arg source_type "$SOURCE_TYPE" \
|
|
482
|
+
--arg source "$SOURCE" \
|
|
483
|
+
--arg staging_path "$STAGING_ROOT" \
|
|
484
|
+
--arg content_path "$CONTENT_DIR" \
|
|
485
|
+
--argjson file_count "$FILE_COUNT" \
|
|
486
|
+
--argjson total_size_bytes "$TOTAL_BYTES" \
|
|
487
|
+
--arg total_size_human "$TOTAL_HUMAN" \
|
|
488
|
+
--argjson symlink_count "$SYMLINK_COUNT" \
|
|
489
|
+
--arg git_ref "$GIT_REF" \
|
|
490
|
+
'{source_type: $source_type,
|
|
491
|
+
source: $source,
|
|
492
|
+
staging_path: $staging_path,
|
|
493
|
+
content_path: $content_path,
|
|
494
|
+
file_count: $file_count,
|
|
495
|
+
total_size_bytes: $total_size_bytes,
|
|
496
|
+
total_size_human: $total_size_human,
|
|
497
|
+
symlink_count: $symlink_count,
|
|
498
|
+
git_ref: (if $git_ref == "" then null else $git_ref end)}'
|
|
499
|
+
else
|
|
500
|
+
if [ -n "$GIT_REF" ]; then
|
|
501
|
+
GIT_REF_JSON="\"$(json_escape "$GIT_REF")\""
|
|
502
|
+
else
|
|
503
|
+
GIT_REF_JSON="null"
|
|
504
|
+
fi
|
|
505
|
+
printf '{"source_type":"%s","source":"%s","staging_path":"%s","content_path":"%s","file_count":%s,"total_size_bytes":%s,"total_size_human":"%s","symlink_count":%s,"git_ref":%s}\n' \
|
|
506
|
+
"$(json_escape "$SOURCE_TYPE")" \
|
|
507
|
+
"$(json_escape "$SOURCE")" \
|
|
508
|
+
"$(json_escape "$STAGING_ROOT")" \
|
|
509
|
+
"$(json_escape "$CONTENT_DIR")" \
|
|
510
|
+
"$FILE_COUNT" \
|
|
511
|
+
"$TOTAL_BYTES" \
|
|
512
|
+
"$(json_escape "$TOTAL_HUMAN")" \
|
|
513
|
+
"$SYMLINK_COUNT" \
|
|
514
|
+
"$GIT_REF_JSON"
|
|
515
|
+
fi
|
|
516
|
+
|
|
517
|
+
exit 0
|