run402 4.130.5 → 4.132.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +6 -0
- package/lib/app-repos.mjs +444 -0
- package/lib/ci.mjs +64 -18
- package/lib/command-manifest.mjs +13 -0
- package/lib/deploy-v2.mjs +1 -1
- package/lib/path-lookup.mjs +9 -4
- package/package.json +1 -1
- package/sdk/dist/index.d.ts +11 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +11 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/app-repos.d.ts +69 -0
- package/sdk/dist/namespaces/app-repos.d.ts.map +1 -0
- package/sdk/dist/namespaces/app-repos.js +312 -0
- package/sdk/dist/namespaces/app-repos.js.map +1 -0
- package/sdk/dist/namespaces/app-repos.types.d.ts +188 -0
- package/sdk/dist/namespaces/app-repos.types.d.ts.map +1 -0
- package/sdk/dist/namespaces/app-repos.types.js +12 -0
- package/sdk/dist/namespaces/app-repos.types.js.map +1 -0
- package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.js +68 -3
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/deploy.types.d.ts +34 -2
- package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.types.js.map +1 -1
- package/sdk/dist/namespaces/snapshots.types.d.ts +2 -0
- package/sdk/dist/namespaces/snapshots.types.d.ts.map +1 -1
- package/sdk/dist/node/deploy-manifest.d.ts.map +1 -1
- package/sdk/dist/node/deploy-manifest.js +28 -2
- package/sdk/dist/node/deploy-manifest.js.map +1 -1
- package/sdk/dist/scoped.d.ts +28 -0
- package/sdk/dist/scoped.d.ts.map +1 -1
- package/sdk/dist/scoped.js +51 -0
- package/sdk/dist/scoped.js.map +1 -1
- package/vault-surface.json +1 -1
package/cli.mjs
CHANGED
|
@@ -42,6 +42,7 @@ BUILD & SHIP — the app and everything it serves
|
|
|
42
42
|
up Provision/link/deploy the current app with SDK orchestration
|
|
43
43
|
projects Manage projects (provision, list, get, sql, delete)
|
|
44
44
|
repos Host-blind encrypted git repos: create/view/list, snapshot/mirror/recover, fsck/gc/access/policy (repo aliases repos)
|
|
45
|
+
app-repos Plain git repos the app owns (list, create, log, cat, ls, clone); not encrypted, not a vault
|
|
45
46
|
deploy Deploy a release; subcommands inspect and steer deploys (requires active tier)
|
|
46
47
|
functions Manage serverless functions (deploy, invoke, logs, list, delete)
|
|
47
48
|
secrets Manage project secrets (set, list, delete)
|
|
@@ -301,6 +302,11 @@ switch (cmd) {
|
|
|
301
302
|
await run(sub, rest);
|
|
302
303
|
break;
|
|
303
304
|
}
|
|
305
|
+
case "app-repos": {
|
|
306
|
+
const { run } = await import("./lib/app-repos.mjs");
|
|
307
|
+
await run(sub, rest);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
304
310
|
case "messages": {
|
|
305
311
|
const { run } = await import("./lib/messages.mjs");
|
|
306
312
|
await run(sub, rest);
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run402 app-repos — plain git repositories owned by a project and written by
|
|
3
|
+
* its app. A deployed function reads and commits through `repo` / `repos`
|
|
4
|
+
* from @run402/functions; this command lists, creates, deletes, reads and
|
|
5
|
+
* clones them. An app repo is unencrypted and is NOT a KyGit vault (that is
|
|
6
|
+
* `run402 repos`).
|
|
7
|
+
*
|
|
8
|
+
* JSON to stdout (pipe contract); thin shim over `r.appRepos.*`. `clone` runs
|
|
9
|
+
* `git clone` with a URL-scoped credential helper (`run402 app-repos
|
|
10
|
+
* credential`, git's credential-helper protocol) that hands git the project's
|
|
11
|
+
* locally stored service key, so no secret lands in the remote URL or the
|
|
12
|
+
* clone's `.git/config`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
17
|
+
import { resolve as resolvePath } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { apiBase, activeProfile, findProject, getProject, resolveProjectId } from "./config.mjs";
|
|
20
|
+
import { getSdk } from "./sdk.mjs";
|
|
21
|
+
import { reportSdkError, fail } from "./sdk-errors.mjs";
|
|
22
|
+
import { assertKnownFlags, flagValue, normalizeArgv, positionalArgs, parseIntegerFlag, failUnknownSubcommand } from "./argparse.mjs";
|
|
23
|
+
import { resolveOnPath } from "./path-lookup.mjs";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_REPO = "default";
|
|
26
|
+
const CLI_ENTRY = fileURLToPath(new URL("../cli.mjs", import.meta.url));
|
|
27
|
+
|
|
28
|
+
const HELP = `run402 app-repos — plain git repositories your app owns (not a KyGit vault)
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
run402 app-repos list [--project <project_id>]
|
|
32
|
+
run402 app-repos create <name> [--from <name>] [--project <project_id>]
|
|
33
|
+
run402 app-repos delete <name> --confirm <name> [--project <project_id>]
|
|
34
|
+
run402 app-repos log [<name>] [--path <path>] [--cursor <cursor>] [--limit <n>] [--project <project_id>]
|
|
35
|
+
run402 app-repos cat [<name>] <path> [--at <commit>] [--project <project_id>]
|
|
36
|
+
run402 app-repos ls [<name>] [<dir>] [--at <commit>] [--project <project_id>]
|
|
37
|
+
run402 app-repos clone [<name>] [<dir>] [--project <project_id>]
|
|
38
|
+
|
|
39
|
+
<name> defaults to "default", the repo every project has without creating it.
|
|
40
|
+
--repo <name> may replace the leading <name> wherever it is optional; --path
|
|
41
|
+
<path> replaces the <dir> of ls, and --dir <dir> the <dir> of clone.
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
--project <project_id> Project (defaults to the active project)
|
|
45
|
+
--from <name> create: start from another repo of the project (shares
|
|
46
|
+
its history and objects; no bytes are copied)
|
|
47
|
+
--confirm <name> delete: the repo name again (default cannot be deleted)
|
|
48
|
+
--path <path> log: only commits that changed this path
|
|
49
|
+
--cursor <cursor> log: next_cursor from the previous page
|
|
50
|
+
--limit <n> log: 1-100 commits (default 50)
|
|
51
|
+
--at <commit> cat / ls: read at this commit instead of the head
|
|
52
|
+
|
|
53
|
+
Prints:
|
|
54
|
+
list { app_repos: [{ repo_name, head, seq, commit_count, size_bytes, size,
|
|
55
|
+
clone_url, created_at, updated_at }] }
|
|
56
|
+
log { commits: [{ commit, parent, seq, author, committer, message,
|
|
57
|
+
committed_at, paths, paths_total, actor_user_id }], head, has_more,
|
|
58
|
+
next_cursor }, newest first
|
|
59
|
+
cat { repo_name, path, commit, oid, content_type, size_bytes, text } for
|
|
60
|
+
UTF-8, or base64 in place of text for other bytes; a missing file is
|
|
61
|
+
APP_REPO_PATH_NOT_FOUND (exit 1)
|
|
62
|
+
ls { commit, path, entries: [{ name, path, type: "file" | "dir", size, oid }] }
|
|
63
|
+
clone { project_id, repo_name, dir, clone_url, credential_helper }
|
|
64
|
+
|
|
65
|
+
clone runs git clone with a credential helper (run402 app-repos credential) that
|
|
66
|
+
gives git the project's service key from this machine's keystore, or
|
|
67
|
+
RUN402_GRANT_KEY when it is set; the key is never written into the remote URL
|
|
68
|
+
or .git/config, and the helper is kept in the clone so git pull and git push
|
|
69
|
+
keep working. main is the only branch and only fast-forward pushes land: undo a
|
|
70
|
+
commit with a revert. Each file is at most 4 MiB: put media in assets and
|
|
71
|
+
commit a reference to it.
|
|
72
|
+
|
|
73
|
+
Auth: the project's service_key, your wallet, a sign-in session, or a grant
|
|
74
|
+
key. Reads need project.app_repos.read (viewer); create, delete and push need
|
|
75
|
+
project.app_repos.write (developer, or a grant with deploy).
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
run402 app-repos list
|
|
79
|
+
run402 app-repos create user-42 --from template
|
|
80
|
+
run402 app-repos log --path people/p-x.json
|
|
81
|
+
run402 app-repos cat site.json
|
|
82
|
+
run402 app-repos ls default people
|
|
83
|
+
run402 app-repos clone default ./content
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
export async function run(sub, args) {
|
|
87
|
+
const rest = Array.isArray(args) ? args : [];
|
|
88
|
+
// git's credential-helper protocol: git passes only the operation, so it is
|
|
89
|
+
// dispatched before the --help check (nothing a person types).
|
|
90
|
+
if (sub === "credential") return credential(rest);
|
|
91
|
+
if (sub === undefined || sub === null || sub === "--help" || sub === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
92
|
+
console.log(HELP);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
switch (sub) {
|
|
96
|
+
case "list": return list(rest);
|
|
97
|
+
case "create": return create(rest);
|
|
98
|
+
case "delete": return remove(rest);
|
|
99
|
+
case "log": return log(rest);
|
|
100
|
+
case "cat": return cat(rest);
|
|
101
|
+
case "ls": return ls(rest);
|
|
102
|
+
case "clone": return clone(rest);
|
|
103
|
+
case "credential": return credential(rest);
|
|
104
|
+
default:
|
|
105
|
+
failUnknownSubcommand("app-repos", sub);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse flags, refuse unknown ones, and return the normalized argv with its positionals. */
|
|
110
|
+
function parse(args, valueFlags, boolFlags = []) {
|
|
111
|
+
const a = normalizeArgv(args);
|
|
112
|
+
assertKnownFlags(a, [...valueFlags, ...boolFlags, "--help", "-h"], valueFlags);
|
|
113
|
+
return { a, pos: positionalArgs(a, valueFlags) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function tooMany(command, extra) {
|
|
117
|
+
fail({
|
|
118
|
+
code: "BAD_USAGE",
|
|
119
|
+
message: `Unexpected argument for app-repos ${command}: ${extra}`,
|
|
120
|
+
hint: "Run `run402 app-repos --help` for usage.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The repo name from a leading positional or --repo, never both. */
|
|
125
|
+
function repoNameFrom(a, positional, command) {
|
|
126
|
+
const flag = flagValue(a, "--repo");
|
|
127
|
+
if (flag && positional && flag !== positional) {
|
|
128
|
+
fail({
|
|
129
|
+
code: "BAD_USAGE",
|
|
130
|
+
message: `app-repos ${command}: --repo ${flag} disagrees with the positional repo name ${positional}`,
|
|
131
|
+
hint: "Name the repo once.",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return flag || positional || DEFAULT_REPO;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function call(fn) {
|
|
138
|
+
try {
|
|
139
|
+
const result = await fn(getSdk().appRepos);
|
|
140
|
+
console.log(JSON.stringify(result, null, 2));
|
|
141
|
+
} catch (err) {
|
|
142
|
+
reportSdkError(err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function list(args) {
|
|
147
|
+
const { a, pos } = parse(args, ["--project"]);
|
|
148
|
+
if (pos.length > 0) tooMany("list", pos[0]);
|
|
149
|
+
const projectId = resolveProjectId(flagValue(a, "--project"));
|
|
150
|
+
await call((ar) => ar.list(projectId));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function create(args) {
|
|
154
|
+
const { a, pos } = parse(args, ["--project", "--from"]);
|
|
155
|
+
if (pos.length === 0) {
|
|
156
|
+
fail({ code: "BAD_USAGE", message: "Missing <name>.", hint: "run402 app-repos create <name> [--from <name>]" });
|
|
157
|
+
}
|
|
158
|
+
if (pos.length > 1) tooMany("create", pos[1]);
|
|
159
|
+
const projectId = resolveProjectId(flagValue(a, "--project"));
|
|
160
|
+
const from = flagValue(a, "--from");
|
|
161
|
+
await call((ar) => ar.create(projectId, { repo_name: pos[0], ...(from ? { from } : {}) }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function remove(args) {
|
|
165
|
+
const { a, pos } = parse(args, ["--project", "--confirm"]);
|
|
166
|
+
if (pos.length === 0) {
|
|
167
|
+
fail({ code: "BAD_USAGE", message: "Missing <name>.", hint: "run402 app-repos delete <name> --confirm <name>" });
|
|
168
|
+
}
|
|
169
|
+
if (pos.length > 1) tooMany("delete", pos[1]);
|
|
170
|
+
const name = pos[0];
|
|
171
|
+
const confirm = flagValue(a, "--confirm");
|
|
172
|
+
if (confirm !== name) {
|
|
173
|
+
fail({
|
|
174
|
+
code: "CONFIRMATION_REQUIRED",
|
|
175
|
+
message: `Deleting app repo ${name} needs --confirm ${name}.`,
|
|
176
|
+
hint: "Its history is gone once no other repo of the project shares its objects.",
|
|
177
|
+
details: { repo_name: name },
|
|
178
|
+
next_actions: [{ type: "run_command", command: `run402 app-repos delete ${name} --confirm ${name}`, why: "confirm the delete by naming the repo again" }],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const projectId = resolveProjectId(flagValue(a, "--project"));
|
|
182
|
+
await call((ar) => ar.delete(projectId, name, { confirm }));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function log(args) {
|
|
186
|
+
const { a, pos } = parse(args, ["--project", "--repo", "--path", "--cursor", "--limit"]);
|
|
187
|
+
if (pos.length > 1) tooMany("log", pos[1]);
|
|
188
|
+
const repoName = repoNameFrom(a, pos[0], "log");
|
|
189
|
+
const projectId = resolveProjectId(flagValue(a, "--project"));
|
|
190
|
+
const path = flagValue(a, "--path");
|
|
191
|
+
const cursor = flagValue(a, "--cursor");
|
|
192
|
+
const rawLimit = flagValue(a, "--limit");
|
|
193
|
+
const limit = rawLimit === null || rawLimit === undefined ? undefined : parseIntegerFlag("--limit", rawLimit, { min: 1, max: 100 });
|
|
194
|
+
await call((ar) => ar.log(projectId, repoName, {
|
|
195
|
+
...(path ? { path } : {}),
|
|
196
|
+
...(cursor ? { cursor } : {}),
|
|
197
|
+
...(limit !== undefined ? { limit } : {}),
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function cat(args) {
|
|
202
|
+
const { a, pos } = parse(args, ["--project", "--repo", "--at"]);
|
|
203
|
+
if (pos.length === 0) {
|
|
204
|
+
fail({ code: "BAD_USAGE", message: "Missing <path>.", hint: "run402 app-repos cat [<name>] <path> [--at <commit>]" });
|
|
205
|
+
}
|
|
206
|
+
if (pos.length > 2) tooMany("cat", pos[2]);
|
|
207
|
+
const [namePos, path] = pos.length === 2 ? pos : [undefined, pos[0]];
|
|
208
|
+
const repoName = repoNameFrom(a, namePos, "cat");
|
|
209
|
+
const projectId = resolveProjectId(flagValue(a, "--project"));
|
|
210
|
+
const at = flagValue(a, "--at");
|
|
211
|
+
let file;
|
|
212
|
+
try {
|
|
213
|
+
file = await getSdk().appRepos.readFile(projectId, repoName, path, at ? { at } : {});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
reportSdkError(err);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!file) {
|
|
219
|
+
fail({
|
|
220
|
+
code: "APP_REPO_PATH_NOT_FOUND",
|
|
221
|
+
message: `No file at ${path} in app repo ${repoName}${at ? ` at ${at}` : ""}.`,
|
|
222
|
+
details: { repo_name: repoName, path, ...(at ? { at } : {}) },
|
|
223
|
+
next_actions: [{ type: "run_command", command: `run402 app-repos ls ${repoName}`, why: "list what the repo holds" }],
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const { bytes, text, ...rest } = file;
|
|
227
|
+
const out = text === null
|
|
228
|
+
? { ...rest, encoding: "base64", base64: Buffer.from(bytes).toString("base64") }
|
|
229
|
+
: { ...rest, text };
|
|
230
|
+
console.log(JSON.stringify(out, null, 2));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function ls(args) {
|
|
234
|
+
const { a, pos } = parse(args, ["--project", "--repo", "--path", "--at"]);
|
|
235
|
+
if (pos.length > 2) tooMany("ls", pos[2]);
|
|
236
|
+
const repoName = repoNameFrom(a, pos[0], "ls");
|
|
237
|
+
const dirFlag = flagValue(a, "--path");
|
|
238
|
+
if (dirFlag && pos[1] && dirFlag !== pos[1]) {
|
|
239
|
+
fail({ code: "BAD_USAGE", message: `app-repos ls: --path ${dirFlag} disagrees with the positional directory ${pos[1]}`, hint: "Name the directory once." });
|
|
240
|
+
}
|
|
241
|
+
const dir = dirFlag || pos[1];
|
|
242
|
+
const projectId = resolveProjectId(flagValue(a, "--project"));
|
|
243
|
+
const at = flagValue(a, "--at");
|
|
244
|
+
await call((ar) => ar.tree(projectId, repoName, dir || undefined, at ? { at } : {}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The git URL of one app repo on the configured API base. */
|
|
248
|
+
export function appRepoGitUrl(base, projectId, repoName) {
|
|
249
|
+
return `${base.replace(/\/+$/, "")}/projects/v1/${encodeURIComponent(projectId)}/app-repos/${encodeURIComponent(repoName)}.git`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* True when `run402` on PATH is this very CLI. The helper must run the CLI
|
|
254
|
+
* that ran the clone: another install on PATH may be older and lack
|
|
255
|
+
* `app-repos`, and git would get "Unknown command" instead of a key.
|
|
256
|
+
*/
|
|
257
|
+
function pathRun402IsThisCli() {
|
|
258
|
+
const found = resolveOnPath("run402");
|
|
259
|
+
if (!found) return false;
|
|
260
|
+
try {
|
|
261
|
+
return realpathSync(found) === realpathSync(CLI_ENTRY);
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The `!…` credential-helper command persisted in the clone. */
|
|
268
|
+
function helperCommand(byName) {
|
|
269
|
+
const parts = [];
|
|
270
|
+
if (process.env.RUN402_API_BASE) parts.push(`RUN402_API_BASE=${shellQuote(process.env.RUN402_API_BASE)}`);
|
|
271
|
+
if (process.env.RUN402_CONFIG_DIR) parts.push(`RUN402_CONFIG_DIR=${shellQuote(process.env.RUN402_CONFIG_DIR)}`);
|
|
272
|
+
parts.push(byName ? "run402" : `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)}`);
|
|
273
|
+
const profile = activeProfile();
|
|
274
|
+
if (profile && profile !== "default") parts.push("--wallet", shellQuote(profile));
|
|
275
|
+
parts.push("app-repos", "credential");
|
|
276
|
+
return `!${parts.join(" ")}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function shellQuote(value) {
|
|
280
|
+
return /^[A-Za-z0-9_./:@%+=-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function clone(args) {
|
|
284
|
+
const { a, pos } = parse(args, ["--project", "--repo", "--dir"]);
|
|
285
|
+
if (pos.length > 2) tooMany("clone", pos[2]);
|
|
286
|
+
const repoName = repoNameFrom(a, pos[0], "clone");
|
|
287
|
+
const dirFlag = flagValue(a, "--dir");
|
|
288
|
+
if (dirFlag && pos[1] && dirFlag !== pos[1]) {
|
|
289
|
+
fail({ code: "BAD_USAGE", message: `app-repos clone: --dir ${dirFlag} disagrees with the positional directory ${pos[1]}`, hint: "Name the directory once." });
|
|
290
|
+
}
|
|
291
|
+
const dir = resolvePath(dirFlag || pos[1] || repoName);
|
|
292
|
+
const projectId = resolveProjectId(flagValue(a, "--project"));
|
|
293
|
+
|
|
294
|
+
// The helper needs a key on this machine: a grant key in the environment,
|
|
295
|
+
// or the project's service key in the keystore (findProject fails with the
|
|
296
|
+
// import next action when it is missing).
|
|
297
|
+
if (!grantKeyFromEnv()) findProject(projectId);
|
|
298
|
+
|
|
299
|
+
if (existsSync(dir)) {
|
|
300
|
+
fail({
|
|
301
|
+
code: "BAD_USAGE",
|
|
302
|
+
message: `Destination ${dir} already exists.`,
|
|
303
|
+
hint: "Clone into a new directory: run402 app-repos clone [<name>] <dir>.",
|
|
304
|
+
details: { dir },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const git = spawnSync("git", ["--version"], { encoding: "utf8" });
|
|
308
|
+
if (git.error || git.status !== 0) {
|
|
309
|
+
fail({
|
|
310
|
+
code: "GIT_NOT_FOUND",
|
|
311
|
+
message: "app-repos clone needs git on PATH.",
|
|
312
|
+
hint: "Install git, then run the clone again.",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Resolve the repo first, so a missing repo is APP_REPO_NOT_FOUND with its
|
|
317
|
+
// create next action rather than a bare git 404.
|
|
318
|
+
try {
|
|
319
|
+
await getSdk().appRepos.get(projectId, repoName);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
reportSdkError(err);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const url = appRepoGitUrl(apiBase(), projectId, repoName);
|
|
326
|
+
const byName = pathRun402IsThisCli();
|
|
327
|
+
const helper = helperCommand(byName);
|
|
328
|
+
// URL-scoped: the empty value clears any helper a global config would ask
|
|
329
|
+
// (so no keychain ever stores the key), then ours answers; useHttpPath
|
|
330
|
+
// hands it the path so it can name the project. `git clone --config`
|
|
331
|
+
// applies these before the first fetch and keeps them in the clone.
|
|
332
|
+
const scope = `credential.${url}`;
|
|
333
|
+
const result = spawnSync("git", [
|
|
334
|
+
"clone",
|
|
335
|
+
"--config", `${scope}.helper=`,
|
|
336
|
+
"--config", `${scope}.helper=${helper}`,
|
|
337
|
+
"--config", `${scope}.useHttpPath=true`,
|
|
338
|
+
url,
|
|
339
|
+
dir,
|
|
340
|
+
], { stdio: ["ignore", 2, 2], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
341
|
+
if (result.error || result.status !== 0) {
|
|
342
|
+
fail({
|
|
343
|
+
code: "GIT_CLONE_FAILED",
|
|
344
|
+
message: `git clone of app repo ${repoName} failed${result.status !== null ? ` (exit ${result.status})` : ""}.`,
|
|
345
|
+
hint: "git's own message is above on stderr.",
|
|
346
|
+
details: { project_id: projectId, repo_name: repoName, clone_url: url, dir },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const out = { project_id: projectId, repo_name: repoName, dir, clone_url: url, credential_helper: "run402 app-repos credential" };
|
|
350
|
+
if (!byName) {
|
|
351
|
+
out.next_actions = [{
|
|
352
|
+
type: "install_cli",
|
|
353
|
+
command: "npm i -g run402",
|
|
354
|
+
why: "the clone's credential helper runs this CLI by its current path, because run402 on PATH is missing or a different install; install this run402 globally so git pull and git push keep finding it",
|
|
355
|
+
}];
|
|
356
|
+
}
|
|
357
|
+
console.log(JSON.stringify(out, null, 2));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function grantKeyFromEnv() {
|
|
361
|
+
const v = process.env.RUN402_GRANT_KEY;
|
|
362
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function readStdin() {
|
|
366
|
+
if (process.stdin.isTTY) return "";
|
|
367
|
+
const chunks = [];
|
|
368
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
369
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Parse git's credential-helper input: `key=value` lines up to a blank line. */
|
|
373
|
+
export function parseCredentialInput(text) {
|
|
374
|
+
const out = {};
|
|
375
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
376
|
+
if (line === "") break;
|
|
377
|
+
const eq = line.indexOf("=");
|
|
378
|
+
if (eq <= 0) continue;
|
|
379
|
+
out[line.slice(0, eq)] = line.slice(eq + 1);
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const APP_REPO_PATH = /^\/?projects\/v1\/([^/]+)\/app-repos\/([^/]+?)\.git(?:\/.*)?$/;
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The project an app-repos credential request is for, or null when the
|
|
388
|
+
* request is not for an app repo on the configured API base.
|
|
389
|
+
*/
|
|
390
|
+
export function credentialTarget(input, base) {
|
|
391
|
+
let api;
|
|
392
|
+
try {
|
|
393
|
+
api = new URL(base);
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
if (input.protocol && `${input.protocol}:` !== api.protocol) return null;
|
|
398
|
+
if (input.host && input.host.toLowerCase() !== api.host.toLowerCase()) return null;
|
|
399
|
+
if (!input.path) return null;
|
|
400
|
+
let path = input.path;
|
|
401
|
+
const basePath = api.pathname.replace(/^\/+|\/+$/g, "");
|
|
402
|
+
if (basePath) {
|
|
403
|
+
if (!path.replace(/^\/+/, "").startsWith(`${basePath}/`)) return null;
|
|
404
|
+
path = path.replace(/^\/+/, "").slice(basePath.length + 1);
|
|
405
|
+
}
|
|
406
|
+
const m = APP_REPO_PATH.exec(path);
|
|
407
|
+
if (!m) return null;
|
|
408
|
+
try {
|
|
409
|
+
return { projectId: decodeURIComponent(m[1]), repoName: decodeURIComponent(m[2]) };
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* `run402 app-repos credential <get|store|erase>` — git's credential-helper
|
|
417
|
+
* protocol (gitcredentials(7)). Not for people: `clone` installs it. On `get`
|
|
418
|
+
* for an app repo on the configured API base it prints `username` and
|
|
419
|
+
* `password` (RUN402_GRANT_KEY when set, else the project's service key from
|
|
420
|
+
* the local keystore) and nothing else; it prints nothing for any other URL.
|
|
421
|
+
* `store` and `erase` are no-ops. The key is written only to git, on stdout.
|
|
422
|
+
*/
|
|
423
|
+
async function credential(args) {
|
|
424
|
+
const op = args[0];
|
|
425
|
+
const input = parseCredentialInput(await readStdin());
|
|
426
|
+
if (op === "store" || op === "erase") return;
|
|
427
|
+
if (op !== "get") {
|
|
428
|
+
fail({
|
|
429
|
+
code: "BAD_USAGE",
|
|
430
|
+
message: "app-repos credential is git's credential helper: get, store or erase, with git's key=value lines on stdin.",
|
|
431
|
+
hint: "run402 app-repos clone installs it; you do not run it yourself.",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
const target = credentialTarget(input, apiBase());
|
|
435
|
+
if (!target) return;
|
|
436
|
+
const password = grantKeyFromEnv() ?? getProject(target.projectId)?.service_key ?? null;
|
|
437
|
+
if (!password) {
|
|
438
|
+
process.stderr.write(
|
|
439
|
+
`run402: no key for project ${target.projectId} on this machine; import one with \`run402 credentials project-keys import --project ${target.projectId} --service-key-stdin\`, or set RUN402_GRANT_KEY.\n`,
|
|
440
|
+
);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
process.stdout.write(`username=run402\npassword=${password}\n`);
|
|
444
|
+
}
|
package/lib/ci.mjs
CHANGED
|
@@ -21,7 +21,7 @@ const { version: RUN402_VERSION } = JSON.parse(
|
|
|
21
21
|
const HELP = `run402 ci — Manage CI/OIDC deploy bindings
|
|
22
22
|
|
|
23
23
|
Usage:
|
|
24
|
-
run402 ci link github [--project <project_id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <repository_id>] [--workflow <path>] [--expires-at <iso>] [--route-scope <pattern> ...] [--force]
|
|
24
|
+
run402 ci link github [--project <project_id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <repository_id>] [--workflow <path> | --no-workflow] [--expires-at <iso>] [--route-scope <pattern> ...] [--force]
|
|
25
25
|
run402 ci list [--project <project_id>]
|
|
26
26
|
run402 ci revoke <binding_id>
|
|
27
27
|
run402 ci set-asset-scopes <binding_id> <scope1> [<scope2> ...]
|
|
@@ -37,16 +37,19 @@ const SUB_HELP = {
|
|
|
37
37
|
link: `run402 ci link github — Link GitHub Actions OIDC for run402 deploy
|
|
38
38
|
|
|
39
39
|
Usage:
|
|
40
|
-
run402 ci link github [--project <project_id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <repository_id>] [--workflow <path>] [--expires-at <iso>] [--route-scope <pattern> ...] [--force]
|
|
40
|
+
run402 ci link github [--project <project_id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <repository_id>] [--workflow <path> | --no-workflow] [--expires-at <iso>] [--route-scope <pattern> ...] [--force]
|
|
41
41
|
|
|
42
42
|
Options:
|
|
43
43
|
--project <project_id> Project ID (defaults to the active project)
|
|
44
44
|
--manifest <path> Manifest path used by the generated workflow (default: run402.deploy.json)
|
|
45
|
-
--repo <owner/repo> GitHub repo (
|
|
45
|
+
--repo <owner/repo> GitHub repo as owner/repo, owner@<id>/repo@<id> (the spelling of an immutable
|
|
46
|
+
OIDC subject, which also gives the repository id) or a github.com URL
|
|
47
|
+
(default: inferred from origin remote)
|
|
46
48
|
--branch <name> Branch subject and push trigger (default: current branch)
|
|
47
49
|
--environment <name> GitHub environment subject; adds job.environment
|
|
48
50
|
--repository-id <repository_id> Numeric GitHub repository id when API lookup is unavailable
|
|
49
51
|
--workflow <path> Workflow path (default: .github/workflows/run402-deploy.yml)
|
|
52
|
+
--no-workflow Create the binding only, for a repository whose workflow already runs run402 deploy
|
|
50
53
|
--expires-at <iso> Optional binding expiration timestamp
|
|
51
54
|
--route-scope <pattern> Optional exact path or final wildcard route scope, repeatable (examples: /admin, /api/*)
|
|
52
55
|
--force Overwrite an existing workflow file
|
|
@@ -107,8 +110,8 @@ function parseFlags(args, allowed, { repeatable = new Set() } = {}) {
|
|
|
107
110
|
details: { flag: arg },
|
|
108
111
|
});
|
|
109
112
|
}
|
|
110
|
-
if (arg === "--force") {
|
|
111
|
-
flags.
|
|
113
|
+
if (arg === "--force" || arg === "--no-workflow") {
|
|
114
|
+
flags[arg.slice(2).replace(/-/g, "_")] = true;
|
|
112
115
|
continue;
|
|
113
116
|
}
|
|
114
117
|
if (!args[i + 1] || args[i + 1].startsWith("--")) {
|
|
@@ -181,6 +184,21 @@ function parseGithubRemote(remote) {
|
|
|
181
184
|
return null;
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
// `@` and `:` cannot appear in a GitHub owner or repository name.
|
|
188
|
+
const GITHUB_REPO_NAME = /^[^/\s@:]+\/[^/\s@:]+$/;
|
|
189
|
+
const GITHUB_REPO_IMMUTABLE = /^([^/\s@:]+)@(\d+)\/([^/\s@:]+)@(\d+)$/;
|
|
190
|
+
|
|
191
|
+
// --repo takes the repository however the owner has it: owner/repo, the
|
|
192
|
+
// owner@<owner_id>/repo@<repo_id> spelling of GitHub's immutable OIDC subject
|
|
193
|
+
// (which also carries the repository id), or a github.com URL. The binding is
|
|
194
|
+
// always written in the name form; the gateway matches either spelling.
|
|
195
|
+
function resolveRepo(value) {
|
|
196
|
+
const immutable = GITHUB_REPO_IMMUTABLE.exec(value);
|
|
197
|
+
if (immutable) return { repo: `${immutable[1]}/${immutable[3]}`, repoId: immutable[4] };
|
|
198
|
+
const repo = parseGithubRemote(value) || value;
|
|
199
|
+
return GITHUB_REPO_NAME.test(repo) ? { repo, repoId: null } : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
184
202
|
function inferBranch() {
|
|
185
203
|
return git(["branch", "--show-current"]);
|
|
186
204
|
}
|
|
@@ -259,6 +277,7 @@ async function linkGithub(args) {
|
|
|
259
277
|
"--environment",
|
|
260
278
|
"--repository-id",
|
|
261
279
|
"--workflow",
|
|
280
|
+
"--no-workflow",
|
|
262
281
|
"--expires-at",
|
|
263
282
|
"--route-scope",
|
|
264
283
|
"--force",
|
|
@@ -277,18 +296,41 @@ async function linkGithub(args) {
|
|
|
277
296
|
});
|
|
278
297
|
}
|
|
279
298
|
|
|
299
|
+
if (flags.no_workflow) {
|
|
300
|
+
const clash = ["--workflow", "--manifest", "--force"].find((flag) => flags[flag.slice(2)] !== undefined);
|
|
301
|
+
if (clash) {
|
|
302
|
+
fail({
|
|
303
|
+
code: "BAD_USAGE",
|
|
304
|
+
message: `--no-workflow writes no workflow, so ${clash} has nothing to apply to.`,
|
|
305
|
+
details: { flag: clash },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
280
310
|
const projectId = resolveProjectId(flags.project);
|
|
281
|
-
const
|
|
282
|
-
if (!
|
|
311
|
+
const resolved = resolveRepo(flags.repo || inferRepo() || "");
|
|
312
|
+
if (!resolved) {
|
|
283
313
|
fail({
|
|
284
314
|
code: "GITHUB_REPO_REQUIRED",
|
|
285
|
-
message:
|
|
315
|
+
message: flags.repo
|
|
316
|
+
? `--repo must be owner/repo, owner@<id>/repo@<id> or a github.com URL; got ${JSON.stringify(flags.repo)}.`
|
|
317
|
+
: "Could not infer GitHub owner/repo from git remote origin.",
|
|
286
318
|
hint: "Pass --repo <owner/repo>.",
|
|
287
319
|
});
|
|
288
320
|
}
|
|
321
|
+
const { repo } = resolved;
|
|
322
|
+
if (resolved.repoId && flags.repository_id && resolved.repoId !== flags.repository_id) {
|
|
323
|
+
fail({
|
|
324
|
+
code: "GITHUB_REPOSITORY_ID_CONFLICT",
|
|
325
|
+
message: `--repo names repository id ${resolved.repoId}, but --repository-id is ${flags.repository_id}.`,
|
|
326
|
+
hint: "Drop --repository-id, or pass the --repo of the repository it names.",
|
|
327
|
+
details: { repo, repo_id_in_repo: resolved.repoId, repository_id: flags.repository_id },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
289
330
|
const branch = flags.environment ? (flags.branch || inferBranch() || null) : (flags.branch || inferBranch());
|
|
290
331
|
const subject = buildSubject(repo, { branch, environment: flags.environment });
|
|
291
|
-
const
|
|
332
|
+
const repositoryIdProvided = flags.repository_id || resolved.repoId;
|
|
333
|
+
const repositoryId = repositoryIdProvided || await fetchGithubRepositoryId(repo);
|
|
292
334
|
if (!repositoryId) {
|
|
293
335
|
fail({
|
|
294
336
|
code: "GITHUB_REPOSITORY_ID_REQUIRED",
|
|
@@ -298,9 +340,9 @@ async function linkGithub(args) {
|
|
|
298
340
|
});
|
|
299
341
|
}
|
|
300
342
|
|
|
301
|
-
const workflowPath = flags.workflow || ".github/workflows/run402-deploy.yml";
|
|
302
|
-
const absWorkflowPath = resolve(process.cwd(), workflowPath);
|
|
303
|
-
if (existsSync(absWorkflowPath) && !flags.force) {
|
|
343
|
+
const workflowPath = flags.no_workflow ? null : flags.workflow || ".github/workflows/run402-deploy.yml";
|
|
344
|
+
const absWorkflowPath = workflowPath && resolve(process.cwd(), workflowPath);
|
|
345
|
+
if (absWorkflowPath && existsSync(absWorkflowPath) && !flags.force) {
|
|
304
346
|
fail({
|
|
305
347
|
code: "WORKFLOW_EXISTS",
|
|
306
348
|
message: `Workflow already exists: ${workflowPath}`,
|
|
@@ -309,7 +351,7 @@ async function linkGithub(args) {
|
|
|
309
351
|
});
|
|
310
352
|
}
|
|
311
353
|
|
|
312
|
-
const manifest = flags.manifest || "run402.deploy.json";
|
|
354
|
+
const manifest = flags.no_workflow ? null : flags.manifest || "run402.deploy.json";
|
|
313
355
|
const routeScopes = Array.isArray(flags.route_scope) ? flags.route_scope : [];
|
|
314
356
|
const nonce = randomBytes(16).toString("hex");
|
|
315
357
|
const values = {
|
|
@@ -334,7 +376,7 @@ async function linkGithub(args) {
|
|
|
334
376
|
});
|
|
335
377
|
}
|
|
336
378
|
|
|
337
|
-
const workflow = generateWorkflow({
|
|
379
|
+
const workflow = workflowPath && generateWorkflow({
|
|
338
380
|
branch,
|
|
339
381
|
environment: flags.environment || null,
|
|
340
382
|
manifest,
|
|
@@ -348,8 +390,10 @@ async function linkGithub(args) {
|
|
|
348
390
|
signed_delegation: signedDelegation,
|
|
349
391
|
});
|
|
350
392
|
const outputRouteScopes = Array.isArray(binding.route_scopes) ? binding.route_scopes : [];
|
|
351
|
-
|
|
352
|
-
|
|
393
|
+
if (absWorkflowPath) {
|
|
394
|
+
mkdirSync(dirname(absWorkflowPath), { recursive: true });
|
|
395
|
+
writeFileSync(absWorkflowPath, workflow, { encoding: "utf8", mode: 0o644 });
|
|
396
|
+
}
|
|
353
397
|
console.log(JSON.stringify({
|
|
354
398
|
binding_id: binding.id,
|
|
355
399
|
project_id: projectId,
|
|
@@ -359,12 +403,14 @@ async function linkGithub(args) {
|
|
|
359
403
|
allowed_actions: [...V1_CI_ALLOWED_ACTIONS],
|
|
360
404
|
route_scopes: outputRouteScopes,
|
|
361
405
|
github_repository_id: repositoryId,
|
|
362
|
-
github_repository_id_status:
|
|
406
|
+
github_repository_id_status: repositoryIdProvided ? "provided" : "verified",
|
|
363
407
|
workflow_path: workflowPath,
|
|
364
408
|
manifest_path: manifest,
|
|
365
409
|
run402_version: RUN402_VERSION,
|
|
366
410
|
delegation_chain_id: DEFAULT_CI_DELEGATION_CHAIN_ID,
|
|
367
|
-
bootstrap_caveat:
|
|
411
|
+
bootstrap_caveat: workflowPath
|
|
412
|
+
? "Commit the generated workflow and manifest before expecting GitHub Actions deploys."
|
|
413
|
+
: "No workflow was written (--no-workflow): the repository's existing workflow deploys once it runs run402 deploy with permissions: id-token: write; re-run it.",
|
|
368
414
|
consent_summary: [
|
|
369
415
|
"This binding lets matching GitHub Actions workflows deploy site, function, and database changes to the project.",
|
|
370
416
|
outputRouteScopes.length > 0
|
package/lib/command-manifest.mjs
CHANGED
|
@@ -252,6 +252,19 @@ export const COMMAND_MANIFEST = [
|
|
|
252
252
|
{ path: ["live"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--tables", "cells", "--once"], runStyle: "merged" },
|
|
253
253
|
// ── push ─────────────────────────────────────────────────────────────────
|
|
254
254
|
{ path: ["push", "status"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
|
255
|
+
// ── app repos (plain git repositories the app owns; not the `repos` vaults) ──
|
|
256
|
+
// `credential` (git's credential-helper protocol on stdin) is installed by
|
|
257
|
+
// `clone` and invoked by git, never by a person, so it has no entry here.
|
|
258
|
+
{ path: ["app-repos", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
|
259
|
+
{ path: ["app-repos", "create"], positionals: [p("repo_name")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["user-42"], runStyle: "sub" },
|
|
260
|
+
{ path: ["app-repos", "delete"], positionals: [p("repo_name")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["user-42", "--confirm", "user-42"], runStyle: "sub" },
|
|
261
|
+
{ path: ["app-repos", "log"], positionals: [p("repo_name", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
|
262
|
+
// A leading repo name is sugar for --repo; the canonical form names only the path.
|
|
263
|
+
{ path: ["app-repos", "cat"], positionals: [p("path")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["site.json"], runStyle: "sub" },
|
|
264
|
+
// A second positional directory is sugar for --path.
|
|
265
|
+
{ path: ["app-repos", "ls"], positionals: [p("repo_name", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
|
266
|
+
// A second positional directory is sugar for --dir.
|
|
267
|
+
{ path: ["app-repos", "clone"], positionals: [p("repo_name", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "runs git clone and writes a working tree" },
|
|
255
268
|
{ path: ["deliveries", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
|
256
269
|
{ path: ["deliveries", "get"], positionals: [p("notification_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["n_1"], runStyle: "sub" },
|
|
257
270
|
{ path: ["contacts", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
package/lib/deploy-v2.mjs
CHANGED
|
@@ -1501,7 +1501,7 @@ const CI_DEPLOY_ERROR_GUIDANCE = {
|
|
|
1501
1501
|
],
|
|
1502
1502
|
},
|
|
1503
1503
|
access_denied: {
|
|
1504
|
-
hint: "
|
|
1504
|
+
hint: "No active binding on this project matches this workflow's OIDC subject (details.subject); next_actions names the command that links it.",
|
|
1505
1505
|
next_actions: [
|
|
1506
1506
|
editRequestAction("run402 ci list --project <prj_...>", "Inspect active CI bindings for this project."),
|
|
1507
1507
|
editRequestAction("run402 ci link github", "Link this repository, branch, and environment to Run402."),
|