Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions bin/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1335,16 +1335,27 @@ quota_project() {
node -e 'try{process.stdout.write(require(process.env.HOME+"/.config/gcloud/application_default_credentials.json").quota_project_id||"")}catch(e){}' 2>/dev/null
}

# Cloud Resource Manager carries the creation time that FirebaseProject omits.
# Newest first also makes a recently created Firebase project a useful quota
# candidate instead of letting an arbitrary API order hide it beyond the limit.
cloud_projects() {
gcloud projects list --format='value(projectId,createTime)' \
--sort-by='~createTime' --limit="${LIMIT:-50}" 2>/dev/null
}

# Quota-project candidates for the project *list*, which runs before any project
# is chosen. Best guess first; duplicates dropped.
# is chosen. Best guess first; duplicates dropped. firebase_projects passes the
# Cloud Resource Manager listing it already fetched so this does not read twice.
quota_candidates() {
local projects
if (($#)); then projects="${1-}"; else projects="$(cloud_projects)"; fi
{
[[ -s "$WS/.quota" ]] && { tr -d '\n' < "$WS/.quota"; echo; }
local p
p="$(gcloud config get-value project 2>/dev/null)"
[[ -n "$p" && "$p" != "(unset)" ]] && echo "$p"
node -e 'try{const q=require(process.env.HOME+"/.config/gcloud/application_default_credentials.json").quota_project_id;if(q)console.log(q)}catch(e){}' 2>/dev/null
gcloud projects list --format='value(projectId)' --limit="${LIMIT:-50}" 2>/dev/null
printf '%s\n' "$projects" | cut -f1
} | awk 'NF && !seen[$0]++'
}

Expand Down Expand Up @@ -1753,15 +1764,44 @@ firebase_projects() {
# instead of having to remember it. rc 10 is "a human is needed", which is what an
# unasked question is — and it is not rc 1, so nothing reads it as "Google said no".
may_list_projects || return 10
local url='https://firebase.googleapis.com/v1beta1/projects?pageSize=100' body p
local base='https://firebase.googleapis.com/v1beta1/projects?pageSize=100'
local body cloud page page_token p projects url
cloud="$(cloud_projects)" || cloud=""
while read -r p; do
body="$(QP="$p" api_get "$url" 2>/dev/null)" || continue
projects=""; page_token=""
while :; do
url="$base"
[[ -n "$page_token" ]] && url="$url&pageToken=$(urlenc "$page_token")"
body="$(QP="$p" api_get "$url" 2>/dev/null)" || { projects=""; break; }
page="$(printf '%s' "$body" | jqn 'process.stdout.write((j.results||[])
.filter(p=>!p.state||p.state==="ACTIVE")
.map(p=>[p.projectId,p.displayName||""].join("\t")).join("\n"))')"
[[ -n "$page" ]] && projects="${projects}${projects:+$'\n'}${page}"
page_token="$(printf '%s' "$body" | jqn 'process.stdout.write(j.nextPageToken||"")')"
[[ -n "$page_token" ]] || break
done
[[ -n "$projects" ]] || continue
ws_init; printf '%s' "$p" > "$WS/.quota"
printf '%s' "$body" | jqn 'process.stdout.write((j.results||[])
.filter(p=>!p.state||p.state==="ACTIVE")
.map(p=>[p.projectId,p.displayName||""].join("\t")).join("\n"))'
{
printf '%s\n' "$cloud"
printf '\036\n'
printf '%s\n' "$projects"
} | node -e '
let s = ""; process.stdin.on("data", d => s += d).on("end", () => {
const [cloud = "", firebase = ""] = s.split("\n\u001e\n");
const created = new Map(cloud.split("\n").filter(Boolean).map(line => {
const [id, time = ""] = line.split("\t"); return [id, time];
}));
firebase.split("\n").filter(Boolean)
.map((line, i) => {
const [id] = line.split("\t");
return { line, i, time: created.get(id) || "" };
})
.sort((a, b) => b.time.localeCompare(a.time) || a.i - b.i)
.forEach(({ line }) => console.log(line));
});'
return 0
done <<< "$(quota_candidates)"
done <<< "$(quota_candidates "$cloud")"
return 1
}

Expand Down
15 changes: 8 additions & 7 deletions bin/questions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -258,18 +258,19 @@ EOF
# and a picker that re-reads their account is a second helping of a one-time yes.

# id \t display name \t app count \t carries our package \t packages — best first.
# Ranked so the zero-mutation answer is the first thing read: a project that already has
# this package needs nothing created in it at all.
# Exact package matches are the zero-mutation answer, so they come first. Discovery
# already returns projects newest first, and the stable tie-break keeps that order
# within the matching and non-matching groups.
ranked_projects() {
[[ -s "$WS/discovered.json" ]] || return 1
PKG="${PACKAGE:-}" node -e '
let s=""; process.stdin.on("data", d => s += d).on("end", () => {
const want = process.env.PKG || "", d = JSON.parse(s);
const tier = p => (p.apps || []).some(a => a.package === want) ? 0 : ((p.apps || []).length ? 1 : 2);
(d.projects || []).map((p, i) => ({ p, i, t: tier(p) }))
.sort((a, b) => a.t - b.t || a.i - b.i)
.forEach(({ p, t }) => console.log([
p.id, p.name || "", (p.apps || []).length, t === 0 ? 1 : 0,
const matches = p => !!want && (p.apps || []).some(a => a.package === want);
(d.projects || []).map((p, i) => ({ p, i, match: matches(p) }))
.sort((a, b) => Number(b.match) - Number(a.match) || a.i - b.i)
.forEach(({ p, match }) => console.log([
p.id, p.name || "", (p.apps || []).length, match ? 1 : 0,
(p.apps || []).map(a => a.package).join(", "),
].join("\t")));
});' < "$WS/discovered.json" 2>/dev/null
Expand Down
18 changes: 10 additions & 8 deletions tests/agent-questions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -464,11 +464,13 @@ echo
DISC="$TMP/disc"; mkdir -p "$DISC"
node -e '
const projects = [];
for (let i = 1; i <= 12; i++) projects.push({ id: `proj-number-${i}`, name: `Project ${i}`, apps: [] });
projects[4].apps = [{ package: "com.other.thing", appId: "1:5:android:aaa" }];
projects[7].apps = [{ package: "com.dogshelter", appId: "1:8:android:bbb" }];
projects[10].apps = [{ package: "com.dogshelter", appId: "1:11:android:ccc" },
{ package: "com.dogshelter.dev", appId: "1:11:android:ddd" }];
for (let i = 12; i >= 1; i--) projects.push({ id: `proj-number-${i}`, name: `Project ${i}`, apps: [] });
projects.find(p => p.id === "proj-number-5").apps = [{ package: "com.other.thing", appId: "1:5:android:aaa" }];
projects.find(p => p.id === "proj-number-8").apps = [{ package: "com.dogshelter", appId: "1:8:android:bbb" }];
projects.find(p => p.id === "proj-number-11").apps = [
{ package: "com.dogshelter", appId: "1:11:android:ccc" },
{ package: "com.dogshelter.dev", appId: "1:11:android:ddd" },
];
process.stdout.write(JSON.stringify({ account: "dev@example.com", projects }));
' > "$DISC/discovered.json"

Expand Down Expand Up @@ -498,9 +500,9 @@ proj="$(qd pick_project)"
# design: a developer whose answer is on page two will type it, and one whose answer is
# ranked first will click it.
labels="$(node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>JSON.parse(s).options.forEach(o=>console.log(o.label)))' <<< "$proj")"
[[ "$(head -1 <<< "$labels")" == proj-number-8 || "$(head -1 <<< "$labels")" == proj-number-11 ]] \
&& ok "a project that already has the package is offered first — the path that creates nothing" \
|| bad "the zero-mutation project is not first: $(tr '\n' ' ' <<< "$labels")"
[[ "$(sed -n '1,3p' <<< "$labels")" == $'proj-number-11\nproj-number-8\nproj-number-12' ]] \
&& ok "package matches come first, then projects are newest first" \
|| bad "the project order is not package match then recency: $(tr '\n' ' ' <<< "$labels")"
grep -qF "9 more not shown" <<< "$proj" \
&& ok "and it admits how many it left out, rather than implying that was all of them" \
|| bad "twelve projects, three shown, and nothing says so"
Expand Down
24 changes: 24 additions & 0 deletions tests/approval-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,30 @@ rc=$?
(( $(http_calls "$WS12") == 0 )) && ok "and made no call while refusing" \
|| bad "firebase_projects reached the network before refusing"

PAGED="$TMP/paged-projects"; mkdir -p "$PAGED"
projects="$(WS="$PAGED" APPROVED=1 bash -c '
source bin/config.sh >/dev/null 2>&1
may_list_projects() { return 0; }
cloud_projects() {
printf "%s\n" \
$'"'"'new-project\t2026-09-29T10:00:00Z'"'"' \
$'"'"'old-project\t2025-01-10T10:00:00Z'"'"'
}
quota_candidates() { echo quota-project; }
api_get() {
case "$1" in
*pageToken=*)
printf "%s" '"'"'{"results":[{"projectId":"new-project","displayName":"New project","state":"ACTIVE"}]}'"'"' ;;
*)
printf "%s" '"'"'{"results":[{"projectId":"old-project","displayName":"Old project","state":"ACTIVE"}],"nextPageToken":"second page"}'"'"' ;;
esac
}
firebase_projects
' < /dev/null)"
[[ "$projects" == $'new-project\tNew project\nold-project\tOld project' ]] \
&& ok "firebase_projects reads every page and returns newest projects first" \
|| bad "firebase_projects lost a page or did not sort by creation time: $(tr '\n' ' ' <<< "$projects")"

echo
echo " A pasted value cannot grant its own consent"
echo
Expand Down
Loading