run402 4.37.1 → 4.38.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.
Files changed (65) hide show
  1. package/README.md +3 -1
  2. package/core-dist/keystore.js +56 -4
  3. package/git-remote-run402.mjs +136 -14
  4. package/lib/command-manifest.mjs +7 -0
  5. package/lib/doctor.mjs +19 -6
  6. package/lib/gitvault-target.mjs +145 -0
  7. package/lib/gitvault.mjs +81 -18
  8. package/lib/next-actions.mjs +25 -0
  9. package/lib/org.mjs +70 -0
  10. package/lib/repos.mjs +130 -5
  11. package/lib/up.mjs +19 -2
  12. package/lib/wallet-context.mjs +88 -12
  13. package/package.json +1 -1
  14. package/sdk/core-dist/keystore.js +56 -4
  15. package/sdk/dist/errors.d.ts +6 -2
  16. package/sdk/dist/errors.d.ts.map +1 -1
  17. package/sdk/dist/errors.js.map +1 -1
  18. package/sdk/dist/index.d.ts +1 -1
  19. package/sdk/dist/index.d.ts.map +1 -1
  20. package/sdk/dist/index.js +1 -1
  21. package/sdk/dist/index.js.map +1 -1
  22. package/sdk/dist/namespaces/gitvault.crypto.d.ts +11 -2
  23. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  24. package/sdk/dist/namespaces/gitvault.crypto.js +11 -2
  25. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  26. package/sdk/dist/namespaces/gitvault.d.ts +109 -3
  27. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  28. package/sdk/dist/namespaces/gitvault.js +161 -12
  29. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  30. package/sdk/dist/namespaces/org.d.ts +18 -1
  31. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  32. package/sdk/dist/namespaces/org.js +38 -0
  33. package/sdk/dist/namespaces/org.js.map +1 -1
  34. package/sdk/dist/namespaces/org.types.d.ts +16 -0
  35. package/sdk/dist/namespaces/org.types.d.ts.map +1 -1
  36. package/sdk/dist/namespaces/projects.d.ts +11 -1
  37. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  38. package/sdk/dist/namespaces/projects.js +16 -0
  39. package/sdk/dist/namespaces/projects.js.map +1 -1
  40. package/sdk/dist/namespaces/projects.types.d.ts +7 -0
  41. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  42. package/sdk/dist/node/gitvault-address.d.ts +89 -0
  43. package/sdk/dist/node/gitvault-address.d.ts.map +1 -0
  44. package/sdk/dist/node/gitvault-address.js +113 -0
  45. package/sdk/dist/node/gitvault-address.js.map +1 -0
  46. package/sdk/dist/node/gitvault-creation-journal.d.ts +81 -9
  47. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  48. package/sdk/dist/node/gitvault-creation-journal.js +93 -17
  49. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  50. package/sdk/dist/node/gitvault-publication.d.ts +13 -0
  51. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  52. package/sdk/dist/node/gitvault-publication.js +1 -0
  53. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  54. package/sdk/dist/node/gitvault-push-to-create.d.ts +75 -0
  55. package/sdk/dist/node/gitvault-push-to-create.d.ts.map +1 -0
  56. package/sdk/dist/node/gitvault-push-to-create.js +132 -0
  57. package/sdk/dist/node/gitvault-push-to-create.js.map +1 -0
  58. package/sdk/dist/node/index.d.ts +6 -2
  59. package/sdk/dist/node/index.d.ts.map +1 -1
  60. package/sdk/dist/node/index.js +8 -1
  61. package/sdk/dist/node/index.js.map +1 -1
  62. package/sdk/dist/scoped.d.ts +2 -1
  63. package/sdk/dist/scoped.d.ts.map +1 -1
  64. package/sdk/dist/scoped.js +3 -0
  65. package/sdk/dist/scoped.js.map +1 -1
package/lib/gitvault.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  import { readFileSync } from "node:fs";
20
20
  import { resolveProjectId } from "./config.mjs";
21
21
  import { resolveOwningOrgId } from "./org-context.mjs";
22
+ import { resolveGitvaultTarget } from "./gitvault-target.mjs";
22
23
  import { getSdk } from "./sdk.mjs";
23
24
  import { reportSdkError, fail } from "./sdk-errors.mjs";
24
25
  import {
@@ -69,8 +70,12 @@ Subcommands:
69
70
  deploy — a vault-only project snapshots for months without one.
70
71
  Against a project with no vault yet, this ALLOCATES one inline
71
72
  (the six-stage creation, same as \`init\`) before publishing — one
72
- command, no prior \`gitvault init\`. The one-shot recovery receipt
73
- and keystore path print to stderr the moment that happens.
73
+ command, no prior \`gitvault init\`. When --repo/--project are
74
+ both omitted and the local run402/origin remote is a slug-form
75
+ address (run402::<org-slug>/<name>), PUSH-TO-CREATES through it
76
+ instead (design D6) — same as pushing that name with \`git\`. The
77
+ one-shot recovery receipt and keystore path print to stderr the
78
+ moment that happens.
74
79
  Before reporting a snapshot as landed the SDK compares finalization
75
80
  receipts against the expected manifest and reads the admitted head
76
81
  back from storage; a 200 alone is never enough. \`push\` is a
@@ -164,19 +169,36 @@ Examples:
164
169
  * Resolve which vault to act on, plus the local git tree.
165
170
  *
166
171
  * `--repo` addresses the vault directly (the cold-restart path: an agent that
167
- * knows its repo_id needs no project lookup). Otherwise the project is
168
- * resolved the CLI-wide way `--project`, then RUN402_PROJECT_ID, then the
169
- * active project — and the SDK resolves the vault from it.
172
+ * knows its repo_id needs no project lookup). Otherwise the project targets,
173
+ * highest first: `--project` > the repo's own pin/remote > RUN402_PROJECT_ID
174
+ * > the active project (repo-first-onramp follow-up, kychee-com/run402#559
175
+ * see `gitvault-target.mjs`'s module doc for the full targeting order and
176
+ * why it exists: a stale active-project pointer used to silently outrank the
177
+ * repository this command is actually standing in).
170
178
  */
171
- function vaultTarget(a) {
179
+ async function vaultTarget(a) {
172
180
  const repoId = flagValue(a, "--repo");
173
181
  const project = flagValue(a, "--project");
174
- const target = { repo_dir: process.cwd() };
182
+ const repoDir = process.cwd();
183
+ const resolved = await resolveGitvaultTarget({
184
+ repoDir,
185
+ explicitProjectId: project ?? undefined,
186
+ explicitRepoId: repoId ?? undefined,
187
+ });
188
+ const target = { repo_dir: repoDir };
175
189
  if (repoId != null) target.repo_id = repoId;
176
190
  // Only demand a project when one is actually needed: `--repo` alone is a
177
- // complete address, and requiring an active project on top of it would make
178
- // the cold-restart path fail for no reason.
179
- if (repoId == null || project != null) target.project_id = resolveProjectId(project);
191
+ // complete address, and requiring one on top of it would make the
192
+ // cold-restart path fail for no reason.
193
+ if (repoId == null || project != null) {
194
+ if ("repo_id" in resolved && project == null) target.repo_id = resolved.repo_id;
195
+ // `resolveGitvaultTarget` reports its last (env/active) tier
196
+ // non-throwingly (`run402 doctor`'s call site needs that) — this call
197
+ // site is the one that historically failed closed with PROJECT_REQUIRED
198
+ // when nothing resolves anywhere, and still does: `resolveProjectId`
199
+ // re-derives the exact same env/active check and throws.
200
+ if ("project_id" in resolved) target.project_id = resolved.project_id ?? resolveProjectId(project);
201
+ }
180
202
  return target;
181
203
  }
182
204
 
@@ -333,7 +355,7 @@ async function policy(args) {
333
355
  });
334
356
  }
335
357
 
336
- const target = vaultTarget(a);
358
+ const target = await vaultTarget(a);
337
359
  try {
338
360
  const sdk = getSdk();
339
361
  const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
@@ -359,7 +381,7 @@ async function status(args) {
359
381
  requirePositionalCount(a, COMMON_VALUE_FLAGS, {
360
382
  min: 0, max: 0, command: "run402 gitvault status", missing: "",
361
383
  });
362
- const target = vaultTarget(a);
384
+ const target = await vaultTarget(a);
363
385
  if (a.includes("--refs")) target.refs = true;
364
386
  try {
365
387
  const s = await getSdk().gitvault.status(target);
@@ -370,6 +392,15 @@ async function status(args) {
370
392
  if (s.remote) {
371
393
  console.error(`remote '${s.remote.name}': ${s.remote.url}${s.remote.matches ? "" : " ← points at a DIFFERENT project than this status"}`);
372
394
  }
395
+ // The id-pinning state (design D6, task 4.5): a slug-form remote pins
396
+ // repo_id in local git state the first time it resolves; id-form pins
397
+ // nothing (it needs no pin — see resolveGitvaultAddress's doc comment).
398
+ if (s.pinned) {
399
+ console.error(
400
+ `pinned: repo_id ${s.pinned.repo_id}` +
401
+ (s.pinned.resolved_from ? ` (resolved from run402::${s.pinned.resolved_from.org_slug}/${s.pinned.resolved_from.repo_name})` : ""),
402
+ );
403
+ }
373
404
  if (s.refs) {
374
405
  const names = Object.keys(s.refs).sort();
375
406
  console.error(names.length === 0 ? "refs: (none yet)" : `refs (${names.length}):`);
@@ -396,6 +427,33 @@ async function status(args) {
396
427
  * only the dispatched SUBCOMMAND name changed (see `run()` below, where
397
428
  * `gitvault push` survives one release as a deprecation-warning alias).
398
429
  */
430
+ /**
431
+ * D6 (repo-first-onramp task 4): when neither `--repo` nor `--project` was
432
+ * given explicitly, look at the local `run402`/`origin` remote (in that
433
+ * order, mirroring `scaffoldRemote`'s own naming) and, if it is a SLUG-form
434
+ * address (`run402::<org-slug>/<name>`), return the parsed address so
435
+ * `snapshot` can push-to-create through it — the same address-form
436
+ * resolution `git push` drives via the remote helper. `null` for an
437
+ * id-form remote, no remote at all, or an explicit `--repo`/`--project`.
438
+ */
439
+ async function detectSlugFormRemote(a, repoDir) {
440
+ if (flagValue(a, "--repo") != null || flagValue(a, "--project") != null) return null;
441
+ const { hardenedGit } = await import("#sdk/node");
442
+ const { parseGitvaultRemoteUrl, gitvaultRemoteAddressForm } = await import("#sdk");
443
+ for (const name of ["run402", "origin"]) {
444
+ let url;
445
+ try {
446
+ url = (await hardenedGit(repoDir, ["remote", "get-url", name])).text().trim();
447
+ } catch {
448
+ continue;
449
+ }
450
+ if (!url) continue;
451
+ const address = parseGitvaultRemoteUrl(url);
452
+ if (address && gitvaultRemoteAddressForm(address) === "slug") return address;
453
+ }
454
+ return null;
455
+ }
456
+
399
457
  async function snapshot(args) {
400
458
  const a = normalizeArgv(args);
401
459
  const valueFlags = [...COMMON_VALUE_FLAGS, "--message"];
@@ -404,14 +462,19 @@ async function snapshot(args) {
404
462
  min: 0, max: 0, command: "run402 gitvault snapshot", missing: "",
405
463
  });
406
464
  const message = flagValue(a, "--message");
407
- const target = vaultTarget(a);
465
+ const repoDir = process.cwd();
466
+ const address = await detectSlugFormRemote(a, repoDir);
408
467
  // D2: lazily allocate the vault on first push when there is a project to
409
468
  // resolve the owning org from — the same resolution `gitvault init` uses.
410
469
  // `--repo`-only addressing has nothing to create FROM (no project_id), so
411
- // it is skipped there, matching `open()`'s own precedence.
412
- const orgId = target.project_id ? await resolveOwningOrgId(target.project_id) : null;
470
+ // it is skipped there, matching `open()`'s own precedence. Skipped
471
+ // entirely for a slug-form remote (`address` above) that resolves
472
+ // through the address, not a project_id, and needs no separate org_id.
473
+ const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
474
+ const orgId = !address && target.project_id ? await resolveOwningOrgId(target.project_id) : null;
413
475
  const opts = {
414
476
  ...target,
477
+ ...(address ? { address } : {}),
415
478
  ...(orgId ? { org_id: orgId } : {}),
416
479
  // The gitvault_commit line is progress, not payload: print it the moment
417
480
  // the snapshot exists, well before the publication round-trips finish, so
@@ -450,7 +513,7 @@ async function compact(args) {
450
513
  min: 0, max: 0, command: "run402 gitvault compact", missing: "",
451
514
  });
452
515
  try {
453
- const result = await getSdk().gitvault.compact(vaultTarget(a));
516
+ const result = await getSdk().gitvault.compact(await vaultTarget(a));
454
517
  console.log(JSON.stringify(result, null, 2));
455
518
  console.error(
456
519
  `checkpoint published at generation ${result.generation}: ` +
@@ -519,7 +582,7 @@ async function prune(args) {
519
582
  hint: "Add --submit, or drop the flags to plan.",
520
583
  });
521
584
  }
522
- const opts = vaultTarget(a);
585
+ const opts = await vaultTarget(a);
523
586
  if (submitting) {
524
587
  opts.submit = {
525
588
  core: readJsonFile("--intent-core", corePath),
@@ -565,7 +628,7 @@ async function verify(args) {
565
628
  requirePositionalCount(a, valueFlags, {
566
629
  min: 0, max: 0, command: "run402 gitvault verify", missing: "",
567
630
  });
568
- const target = vaultTarget(a);
631
+ const target = await vaultTarget(a);
569
632
  const budget = flagValue(a, "--budget");
570
633
  if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
571
634
  try {
@@ -66,3 +66,28 @@ export function deployAction() {
66
66
  why: "Apply your release manifest to deploy.",
67
67
  });
68
68
  }
69
+
70
+ /**
71
+ * `repos create` (and `gitvault init`) on an org with no claimed slug: the
72
+ * response's `address: null` had no pointer to WHY, or to the named-addressing
73
+ * feature at all (kychee-com/run402#560). One-time $1 fee, owner-only.
74
+ */
75
+ export function claimOrgSlugAction() {
76
+ return nextAction("claim_org_slug", {
77
+ command: "run402 org slug <slug>",
78
+ why: "This organization has no claimed slug yet, so its repos have no run402::<slug>/<name> address. One-time $1, owner-only.",
79
+ });
80
+ }
81
+
82
+ /**
83
+ * The org already has a slug, but this project's address-form repo name was
84
+ * not claimed this time (a collision, or the best-effort claim failed for
85
+ * some other reason) — point at the explicit claim verb instead of leaving
86
+ * `address: null` unexplained.
87
+ */
88
+ export function claimRepoNameAction(projectId) {
89
+ return nextAction("claim_repo_name", {
90
+ command: `run402 repos name <name> --project ${projectId}`,
91
+ why: "The owning organization has a slug, but this project has no claimed address-form name yet.",
92
+ });
93
+ }
package/lib/org.mjs CHANGED
@@ -30,6 +30,7 @@ Usage:
30
30
  run402 org get <org_id>
31
31
  run402 org rename <org_id> --name <display_name> (or: --clear to remove the label)
32
32
  run402 org payout-wallet <org_id> --wallet <wallet_address> (or: --clear to remove the explicit default)
33
+ run402 org slug <slug> [--org <org_id>]
33
34
  run402 org whoami
34
35
  run402 org use <org_id>
35
36
  run402 org current
@@ -53,6 +54,9 @@ Subcommands:
53
54
  list Orgs you are a member of
54
55
  get Read one org (label + tier/lease + your role)
55
56
  rename Set or clear an org's display label (owner-only)
57
+ slug Claim or rename the org's globally-unique, address-form slug
58
+ (owner-only). A genesis claim spends a one-time claim fee; a
59
+ rename releases the old slug into a ~90-day cooldown.
56
60
  use Select the current org for this wallet profile
57
61
  current Report the resolved current org and where it came from
58
62
  clear Clear this wallet profile's org selection
@@ -120,6 +124,26 @@ Legacy (still supported):
120
124
 
121
125
  Owner-only + step-up gated. Pass --clear (or an empty display_name) to remove
122
126
  the label. Output includes the updated tier and lease timestamps.
127
+ `,
128
+ slug: `run402 org slug — claim or rename the org's address-form slug
129
+
130
+ Usage:
131
+ run402 org slug <slug> [--org <org_id>] [--idempotency-key <key>]
132
+
133
+ The slug is a globally-unique, claimable, address-form handle for the org
134
+ (repo-first-onramp design D6) — the <org-slug> half of a named repo address
135
+ run402::<org-slug>/<name>. Grammar: lowercase [a-z0-9-], no leading/trailing/
136
+ double hyphen, max 39 chars. Owner-only.
137
+
138
+ A genesis claim (the org had no prior slug) spends a one-time claim fee off
139
+ the org's balance. A rename is free but releases the OLD slug into a ~90-day
140
+ cooldown: it stops resolving, with a typed SLUG_RELEASED refusal naming the
141
+ new slug as successor — there is no redirect, so update every remote and
142
+ address that still names the old one.
143
+
144
+ This is a paid, side-effecting mutation and requires Idempotency-Key; the SDK
145
+ generates one automatically unless --idempotency-key is passed, so a retried
146
+ call after a dropped response can never double-bill.
123
147
  `,
124
148
  "payout-wallet": `run402 org payout-wallet — set or clear the tenant route payout wallet
125
149
 
@@ -413,6 +437,51 @@ async function payoutWallet(args) {
413
437
  }
414
438
  }
415
439
 
440
+ /**
441
+ * `run402 org slug <slug>` — claim or rename the org's address-form slug
442
+ * (repo-first-onramp design D6). Owner-only, and a genesis claim spends a
443
+ * one-time claim fee — this is a PAID, side-effecting mutation, so it
444
+ * requires `Idempotency-Key`; the SDK generates one client-side when
445
+ * `--idempotency-key` is omitted, so a retried call after a dropped response
446
+ * can never double-bill.
447
+ */
448
+ async function slug(args) {
449
+ const a = normalizeArgv(args);
450
+ const valueFlags = ["--org", "--idempotency-key"];
451
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
452
+ const [newSlug] = requirePositionalCount(a, valueFlags, {
453
+ min: 1,
454
+ max: 1,
455
+ command: "run402 org slug <slug> [--org <org_id>]",
456
+ missing: "Missing <slug>.",
457
+ });
458
+ const org = await resolveOrg(a, { cmd: "org" });
459
+ if (!org) {
460
+ fail({
461
+ code: "ORG_UNRESOLVED",
462
+ message: "Could not resolve which organization to claim this slug for.",
463
+ hint: "Pass --org <org_id>, or select one first with `run402 org use <id>`.",
464
+ });
465
+ }
466
+ const idempotencyKey = flagValue(a, "--idempotency-key");
467
+ try {
468
+ const result = await getSdk().org(org.orgId).claimSlug(newSlug, idempotencyKey != null ? { idempotencyKey } : {});
469
+ console.log(JSON.stringify(result, null, 2));
470
+ if (result.created) {
471
+ console.error(`slug "${result.slug}" claimed for ${org.orgId} — a one-time claim fee was debited from the org's balance.`);
472
+ } else if (result.previous_slug && result.previous_slug !== result.slug) {
473
+ console.error(
474
+ `org ${org.orgId} renamed from "${result.previous_slug}" to "${result.slug}" — no fee. ` +
475
+ `"${result.previous_slug}" now enters its ~90-day release cooldown: it stops resolving with a typed SLUG_RELEASED refusal (naming "${result.slug}" as the successor), never a redirect. Update every remote and address that still names it.`,
476
+ );
477
+ } else {
478
+ console.error(`"${result.slug}" was already ${org.orgId}'s current slug — nothing changed, no fee.`);
479
+ }
480
+ } catch (err) {
481
+ reportSdkError(err);
482
+ }
483
+ }
484
+
416
485
  async function audit(args) {
417
486
  const a = normalizeArgv(args);
418
487
  const valueFlags = ["--limit", "--after", "--before"];
@@ -630,6 +699,7 @@ export async function run(sub, args) {
630
699
  case "get": await get(args); break;
631
700
  case "rename": await rename(args); break;
632
701
  case "payout-wallet": await payoutWallet(args); break;
702
+ case "slug": await slug(args); break;
633
703
  case "whoami": await whoami(args); break;
634
704
  case "use": await use(args); break;
635
705
  case "current": await current(args); break;
package/lib/repos.mjs CHANGED
@@ -35,11 +35,12 @@
35
35
  import { getSdk } from "./sdk.mjs";
36
36
  import { reportSdkError, fail } from "./sdk-errors.mjs";
37
37
  import { withAutoApprove } from "./operator.mjs";
38
- import { allowanceAuthHeaders, isCoreApiTarget } from "./config.mjs";
38
+ import { allowanceAuthHeaders, isCoreApiTarget, resolveProjectId } from "./config.mjs";
39
39
  import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.js";
40
40
  import { resolveOrgId, resolveOwningOrgId } from "./org-context.mjs";
41
- import { nextAction } from "./next-actions.mjs";
41
+ import { nextAction, claimOrgSlugAction, claimRepoNameAction } from "./next-actions.mjs";
42
42
  import { printKeystoreLocation } from "./gitvault.mjs";
43
+ import { gitvaultRemoteUrlForRepo } from "#sdk";
43
44
  import {
44
45
  normalizeArgv,
45
46
  hasHelp,
@@ -56,21 +57,30 @@ Usage:
56
57
  run402 repos create <name> [--org <org_id>] [--dir <path>] [--tier <tier>]
57
58
  run402 repos list [--org <org_id>]
58
59
  run402 repos delete <project_id> [--force]
60
+ run402 repos name <name> [--project <id>]
59
61
 
60
62
  Subcommands:
61
63
  create Provision a project, ALLOCATE its vault (mints key material and a
62
64
  one-shot recovery receipt), and scaffold the run402 remote —
63
65
  origin when free, run402 when taken (D1). No deploy plan, no
64
66
  release, nothing deployed: the vault-only track (design D8), for a
65
- project that only ever hosts encrypted source.
67
+ project that only ever hosts encrypted source. When the owning org
68
+ has a slug (run402 org slug), also claims the project's address-
69
+ form repo name (best-effort — a name collision or missing slug
70
+ never fails the command) and prints the run402::<slug>/<name>
71
+ address (design D6).
66
72
  list The organization's vault-bearing projects — those with an
67
73
  allocated vault, whether or not they have ever deployed. Not
68
- every project in the org; ones with no vault are omitted.
74
+ every project in the org; ones with no vault are omitted. Shows
75
+ the run402::<slug>/<name> address for a repo that has claimed one.
69
76
  delete Delete the project and everything in it (database, functions,
70
77
  subdomains, mailbox, secrets). REFUSES while the vault holds any
71
78
  admitted generation unless --force is passed — this is
72
79
  irreversible and destroys the vault's entire encrypted history
73
80
  along with everything else.
81
+ name Claim or rename the project's per-org-unique, address-form name
82
+ (design D6) — the <name> half of run402::<org-slug>/<name>. No
83
+ fee, unlike the org slug. Same authority as renaming the project.
74
84
 
75
85
  Options:
76
86
  --org <org_id> create/list: the owning organization. create resolves it
@@ -88,6 +98,8 @@ Options:
88
98
  derived from the name)
89
99
  --force delete: proceed even though the vault holds generations
90
100
  that would be permanently and irrecoverably lost
101
+ --project <id> name: project to claim the repo name for (default: the
102
+ active project)
91
103
  --json No-op: stdout is already JSON.
92
104
 
93
105
  There is no separate gitvault price: bytes count against the same
@@ -97,6 +109,7 @@ organization-pooled storage budget every project already has.
97
109
  const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key"];
98
110
  const LIST_VALUE_FLAGS = ["--org"];
99
111
  const DELETE_VALUE_FLAGS = ["--project"];
112
+ const NAME_VALUE_FLAGS = ["--project"];
100
113
 
101
114
  function validateProjectName(name) {
102
115
  if (name === "") {
@@ -123,6 +136,24 @@ function validateProjectName(name) {
123
136
  }
124
137
  }
125
138
 
139
+ /**
140
+ * Best-effort slugify for the address-form repo name (design D6's grammar:
141
+ * lowercase [a-z0-9-], no leading/trailing/double hyphen, <=63 chars). The
142
+ * free-text project display name (`repos create <name>`'s positional) is
143
+ * NOT already in this charset, so `create` derives a candidate rather than
144
+ * sending the raw name straight to the claim route and failing on the first
145
+ * space or capital letter.
146
+ */
147
+ function slugifyRepoName(name) {
148
+ return name
149
+ .toLowerCase()
150
+ .replace(/[^a-z0-9-]+/g, "-")
151
+ .replace(/-+/g, "-")
152
+ .replace(/^-+|-+$/g, "")
153
+ .slice(0, 63)
154
+ .replace(/-+$/g, "");
155
+ }
156
+
126
157
  async function create(args) {
127
158
  const a = normalizeArgv(args);
128
159
  assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h"], CREATE_VALUE_FLAGS);
@@ -177,21 +208,63 @@ async function create(args) {
177
208
  project_id: provisioned.project_id,
178
209
  repo_dir: dir,
179
210
  });
211
+ // Best-effort address-form name claim (design D6): when the owning org
212
+ // has a slug, name this repo so it is reachable as
213
+ // run402::<slug>/<name> too — never fails `create` itself. A collision,
214
+ // a missing slug, or any other refusal just means no address this time;
215
+ // `run402 repos name <name>` claims it explicitly later.
216
+ let address = null;
217
+ let orgSlug = null;
218
+ try {
219
+ const orgRecord = await getSdk().org(effectiveOrgId).get();
220
+ orgSlug = orgRecord.slug ?? null;
221
+ if (orgSlug) {
222
+ const candidate = slugifyRepoName(name);
223
+ if (candidate) {
224
+ const named = await getSdk().projects.setRepoName(provisioned.project_id, candidate);
225
+ address = gitvaultRemoteUrlForRepo(orgSlug, named.repo_name);
226
+ }
227
+ }
228
+ } catch (err) {
229
+ console.error(`repo name not claimed (non-fatal): ${err?.message ?? String(err)}`);
230
+ }
231
+
232
+ // `address: null` used to have no pointer to WHY, or to the
233
+ // named-addressing feature at all (kychee-com/run402#560): an agent
234
+ // reading the output had no path from "address is null" to
235
+ // `run402 org slug`/`run402 repos name`. One typed next_actions entry,
236
+ // pointing at whichever half is actually missing.
237
+ const nextActions = address
238
+ ? []
239
+ : orgSlug
240
+ ? [claimRepoNameAction(provisioned.project_id)]
241
+ : [claimOrgSlugAction()];
242
+
180
243
  const out = {
181
244
  project_id: provisioned.project_id,
182
245
  repo_id: vault.repo_id,
246
+ address,
183
247
  remote: vault.remote,
184
248
  deduplicated: vault.deduplicated,
185
249
  genesis_sha256: vault.genesis_sha256,
186
250
  recovery_receipt: vault.recovery_receipt,
187
251
  terminal_loss_statement: vault.terminal_loss_statement,
188
252
  deployed: false,
253
+ next_actions: nextActions,
189
254
  };
190
255
  console.log(JSON.stringify(out, null, 2));
191
256
  console.error(
192
257
  `project ${provisioned.project_id} provisioned; vault ${vault.repo_id} ` +
193
258
  (vault.deduplicated ? "already existed — nothing was re-allocated" : `allocated (genesis ${vault.genesis_sha256})`),
194
259
  );
260
+ if (address) console.error(`address: ${address}`);
261
+ else if (!orgSlug) {
262
+ console.error(
263
+ "no named address yet — claim an org slug (run402 org slug <slug>, one-time $1) to get run402::<slug>/<name> addresses",
264
+ );
265
+ } else {
266
+ console.error(`no address claimed — run 'run402 repos name <name> --project ${provisioned.project_id}' to claim one`);
267
+ }
195
268
  if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
196
269
  console.error("");
197
270
  console.error(vault.terminal_loss_statement);
@@ -244,8 +317,21 @@ async function list(args) {
244
317
  genesis_admitted_at: status.vault.genesis_admitted_at,
245
318
  });
246
319
  }
247
- console.log(JSON.stringify({ org_id: orgId, repos }, null, 2));
320
+ // The org's slug, when claimed (design D6) — printed so a human/agent can
321
+ // construct run402::<slug>/<name> addresses by hand. There is deliberately
322
+ // no per-project `address` field here yet: the gateway has no bulk (or
323
+ // even single) READ for a project's claimed repo_name today, only the
324
+ // WRITE route (`POST /projects/v1/:id/repo-name`) — adding one is gateway
325
+ // work, out of scope for this client-only change (see the final report).
326
+ let orgSlug = null;
327
+ try {
328
+ orgSlug = (await getSdk().org(orgId).get()).slug;
329
+ } catch {
330
+ // Best-effort — `list` must not fail over an org-slug lookup.
331
+ }
332
+ console.log(JSON.stringify({ org_id: orgId, org_slug: orgSlug, repos }, null, 2));
248
333
  console.error(`${repos.length} vault-bearing project(s) of ${projects.length} total in this organization`);
334
+ if (orgSlug) console.error(`org slug: ${orgSlug} — a repo with a claimed address-form name is reachable at run402::${orgSlug}/<name>`);
249
335
  }
250
336
 
251
337
  async function del(args) {
@@ -297,6 +383,41 @@ async function del(args) {
297
383
  }
298
384
  }
299
385
 
386
+ /**
387
+ * `run402 repos name <name> [--project <id>]` — the explicit address-form
388
+ * claim (design D6, task 4.2): a project gets its per-org-unique `<name>`
389
+ * half of `run402::<org-slug>/<name>` either at push-to-create time or here.
390
+ */
391
+ async function name(args) {
392
+ const a = normalizeArgv(args);
393
+ assertKnownFlags(a, [...NAME_VALUE_FLAGS, "--help", "-h"], NAME_VALUE_FLAGS);
394
+ const [repoName] = requirePositionalCount(a, NAME_VALUE_FLAGS, {
395
+ min: 1, max: 1, command: "run402 repos name <name> [--project <id>]", missing: "run402 repos name <name>: a name is required",
396
+ });
397
+ const projectId = resolveProjectId(flagValue(a, "--project"));
398
+ try {
399
+ const result = await getSdk().projects.setRepoName(projectId, repoName);
400
+ let address = null;
401
+ try {
402
+ const owningOrg = await resolveOwningOrgId(projectId);
403
+ const orgSlug = owningOrg ? (await getSdk().org(owningOrg).get()).slug : null;
404
+ if (orgSlug) address = gitvaultRemoteUrlForRepo(orgSlug, result.repo_name);
405
+ } catch {
406
+ // The claim itself already succeeded — a failed address-preview lookup is never fatal.
407
+ }
408
+ console.log(JSON.stringify({ ...result, address }, null, 2));
409
+ console.error(
410
+ result.previous_repo_name && result.previous_repo_name !== result.repo_name
411
+ ? `renamed from "${result.previous_repo_name}" to "${result.repo_name}"`
412
+ : `name "${result.repo_name}" claimed for ${projectId}`,
413
+ );
414
+ if (address) console.error(`address: ${address}`);
415
+ else console.error("this org has no slug yet — claim one with `run402 org slug <slug>` to get a full run402::<slug>/<name> address");
416
+ } catch (err) {
417
+ reportSdkError(err);
418
+ }
419
+ }
420
+
300
421
  export async function run(sub, args) {
301
422
  const argv = Array.isArray(args) ? args : [];
302
423
  if (!sub || hasHelp([sub, ...argv])) {
@@ -316,6 +437,10 @@ export async function run(sub, args) {
316
437
  await del(argv);
317
438
  break;
318
439
  }
440
+ case "name": {
441
+ await name(argv);
442
+ break;
443
+ }
319
444
  default:
320
445
  failUnknownSubcommand("repos", sub, {
321
446
  hint: "Run `run402 repos --help` for usage.",
package/lib/up.mjs CHANGED
@@ -585,14 +585,31 @@ function looksLikeGitRemoteUrl(source) {
585
585
  return /^[a-z][a-z0-9+.-]*:\/\//i.test(source) || /^[^\s@]+@[^\s:]+:/.test(source);
586
586
  }
587
587
 
588
- /** `git init` only when `dir` is not a repository yet. Returns whether it did. */
588
+ /**
589
+ * `git init` only when `dir` is not a repository yet. Returns whether it did.
590
+ *
591
+ * `-b main`, not whatever `init.defaultBranch` (or the pre-2.28 hardcoded
592
+ * `master`) happens to be — the docs teach `git push origin main`, and the
593
+ * gitvault remote helper's own dangling-HEAD hazard note (a first push of
594
+ * any OTHER branch leaves HEAD naming a ref that does not exist yet) is
595
+ * exactly what a mismatched default branch here would walk `up` straight
596
+ * into. `-b` needs git 2.28+ (2020); an older git falls back to the same
597
+ * result by a different route — `symbolic-ref` on a still-empty repository
598
+ * has no existing ref to disturb, so it is exactly as safe as `-b main`
599
+ * would have been. Mirrors `Gitvault.scaffoldRemote`'s identical fallback.
600
+ */
589
601
  async function gitInitIfNeeded(dir) {
590
602
  const { hardenedGit } = await import("#sdk/node");
591
603
  try {
592
604
  await hardenedGit(dir, ["rev-parse", "--git-dir"]);
593
605
  return false;
594
606
  } catch {
595
- await hardenedGit(dir, ["init", "-q", "-b", "main", "."]);
607
+ try {
608
+ await hardenedGit(dir, ["init", "-q", "-b", "main", "."]);
609
+ } catch {
610
+ await hardenedGit(dir, ["init", "-q", "."]);
611
+ await hardenedGit(dir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
612
+ }
596
613
  return true;
597
614
  }
598
615
  }