@nurix/nustack 0.10.0-dev.12 → 0.10.0-dev.14
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/dist/commands/bootstrap.js +4 -4
- package/dist/commands/docs.js +13 -13
- package/dist/commands/doctor.js +13 -13
- package/dist/commands/handoff.js +8 -8
- package/dist/commands/init.js +43 -43
- package/dist/commands/{workspace-sync.js → project-sync.js} +5 -5
- package/dist/commands/{workspace.js → project.js} +133 -133
- package/dist/commands/review.js +5 -5
- package/dist/commands/search.js +7 -7
- package/dist/commands/sessions.js +1 -1
- package/dist/commands/sync.js +10 -10
- package/dist/commands/toolkit.js +19 -19
- package/dist/index.js +29 -29
- package/dist/lib/etna/profile.js +9 -9
- package/dist/lib/etna/reconcile.js +11 -11
- package/dist/lib/git-sync/core/engine.js +4 -4
- package/dist/lib/git-sync/inbound/apply_commit.js +3 -3
- package/dist/lib/git-sync/inbound/approval.js +1 -1
- package/dist/lib/git-sync/inbound/download.js +8 -8
- package/dist/lib/git-sync/inbound/event_sync.js +13 -13
- package/dist/lib/git-sync/inbound/notification.js +1 -1
- package/dist/lib/git-sync/outbound/extraction.js +4 -4
- package/dist/lib/git-sync/outbound/history_decision.js +2 -2
- package/dist/lib/git-sync/outbound/publisher.js +2 -2
- package/dist/lib/git-sync/outbound/queue.js +3 -3
- package/dist/lib/git-sync/outbound/upload_client.js +4 -4
- package/dist/lib/git-sync/whitelist/index.js +1 -1
- package/dist/lib/git-sync/whitelist/types.js +1 -1
- package/dist/lib/git-sync/whitelist/validation.js +5 -5
- package/dist/lib/lanes/supervisor.js +8 -8
- package/dist/lib/local_store/accepted_uploads.js +5 -5
- package/dist/lib/local_store/bindings.js +11 -11
- package/dist/lib/local_store/index.js +1 -1
- package/dist/lib/local_store/migrations.js +13 -0
- package/dist/lib/local_store/project_events_cache.js +54 -0
- package/dist/lib/nustack_client.js +45 -45
- package/dist/lib/onboarding/attach_existing_git.js +7 -7
- package/dist/lib/onboarding/binding.js +11 -11
- package/dist/lib/onboarding/folder_state.js +10 -10
- package/dist/lib/onboarding/handoff_apply.js +2 -2
- package/dist/lib/onboarding/init_fresh_folder.js +18 -18
- package/dist/lib/onboarding/manifest_writer.js +31 -31
- package/dist/lib/onboarding/web_first_attach.js +12 -12
- package/dist/lib/{workspace_context.js → project_context.js} +24 -24
- package/dist/lib/review/client.js +1 -1
- package/dist/lib/session_telemetry.js +2 -2
- package/dist/lib/sessions/client.js +8 -8
- package/dist/lib/sessions/outsideSessions.js +3 -3
- package/dist/lib/telemetry/config_renderer.js +3 -3
- package/dist/lib/telemetry/health_report.js +1 -1
- package/package.json +3 -3
- package/dist/lib/local_store/workspace_events_cache.js +0 -54
|
@@ -7,24 +7,24 @@ import { runRepositoryOnboarding, } from "./binding.js";
|
|
|
7
7
|
export async function attachExistingGit(ctx, options) {
|
|
8
8
|
const state = classifyFolder(ctx.cwd);
|
|
9
9
|
if (state.class !== "git_worktree" || !state.repository || !state.git) {
|
|
10
|
-
return { ok: false, code: "NOT_A_WORKTREE", message: "`nustack
|
|
10
|
+
return { ok: false, code: "NOT_A_WORKTREE", message: "`nustack project attach` needs an existing Git worktree — run `nustack init` to establish one." };
|
|
11
11
|
}
|
|
12
12
|
const rejection = attachRejection(state.git);
|
|
13
13
|
if (rejection) {
|
|
14
14
|
return { ok: false, code: rejection.toUpperCase(), message: rejectionMessage(rejection) };
|
|
15
15
|
}
|
|
16
|
-
if (state.git.
|
|
16
|
+
if (state.git.markerProjectId && state.git.markerProjectId !== options.projectId && !options.confirmRebind) {
|
|
17
17
|
return {
|
|
18
18
|
ok: false,
|
|
19
19
|
code: "REBIND_CONFIRMATION_REQUIRED",
|
|
20
|
-
message: `This repository is bound to
|
|
20
|
+
message: `This repository is bound to Project ${state.git.markerProjectId}. Re-run with confirmation to rebind it to ${options.projectId}.`,
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
const boundBranch = readSymbolicBranch(state.repository);
|
|
24
24
|
if (!boundBranch) {
|
|
25
25
|
return { ok: false, code: "DETACHED_HEAD", message: "HEAD is detached — check out a branch before attaching." };
|
|
26
26
|
}
|
|
27
|
-
const profile = await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, options.
|
|
27
|
+
const profile = await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, options.projectId);
|
|
28
28
|
const baselineCommitId = readHeadCommit(state.repository);
|
|
29
29
|
const result = await runRepositoryOnboarding({
|
|
30
30
|
store: ctx.store,
|
|
@@ -32,7 +32,7 @@ export async function attachExistingGit(ctx, options) {
|
|
|
32
32
|
accessToken: ctx.accessToken,
|
|
33
33
|
deviceRef: ctx.deviceRef,
|
|
34
34
|
flow: "existing_git",
|
|
35
|
-
|
|
35
|
+
projectId: options.projectId,
|
|
36
36
|
organizationId: options.organizationId,
|
|
37
37
|
repository: state.repository,
|
|
38
38
|
boundBranch,
|
|
@@ -55,8 +55,8 @@ async function reconcileLatest(ctx, bindingId) {
|
|
|
55
55
|
}
|
|
56
56
|
function rejectionMessage(rejection) {
|
|
57
57
|
switch (rejection) {
|
|
58
|
-
case "
|
|
59
|
-
return "This repository is nested inside another NuStack
|
|
58
|
+
case "nested_project":
|
|
59
|
+
return "This repository is nested inside another NuStack Project — one Project has one logical repository root (F-014).";
|
|
60
60
|
case "operation_in_progress":
|
|
61
61
|
return "A merge, rebase, cherry-pick, or revert is in progress — finish or abort it, then re-attach.";
|
|
62
62
|
case "detached_head":
|
|
@@ -3,21 +3,21 @@ import { createBinding } from "../nustack_client.js";
|
|
|
3
3
|
import { getBindingById, getBindingByRepositoryRoot, insertBinding, updateBindingCursor, updateBindingStatus, } from "../local_store/bindings.js";
|
|
4
4
|
import { ensureBindingTelemetry } from "../session_telemetry.js";
|
|
5
5
|
import { readHeadCommit } from "../git-sync/core/repository.js";
|
|
6
|
-
import {
|
|
6
|
+
import { writeProjectManifests } from "./manifest_writer.js";
|
|
7
7
|
import { installToolkit } from "../etna/manager.js";
|
|
8
8
|
import { serializeProfileForBinding } from "../etna/profile.js";
|
|
9
9
|
import { SYNC_PROTOCOL_VERSION } from "../git-sync/whitelist/types.js";
|
|
10
10
|
export function computeRepositoryFingerprint(repository) {
|
|
11
11
|
return createHash("sha256").update(repository.commonDir).digest("hex");
|
|
12
12
|
}
|
|
13
|
-
function bindingIdempotencyKey(
|
|
14
|
-
return createHash("sha256").update(`binding:${
|
|
13
|
+
function bindingIdempotencyKey(projectId, deviceRef, fingerprint) {
|
|
14
|
+
return createHash("sha256").update(`binding:${projectId}:${deviceRef}:${fingerprint}`).digest("hex");
|
|
15
15
|
}
|
|
16
16
|
export async function establishBinding(input) {
|
|
17
17
|
const { store, repository } = input;
|
|
18
18
|
const fingerprint = computeRepositoryFingerprint(repository);
|
|
19
19
|
const existing = getBindingByRepositoryRoot(store, repository.repositoryRoot);
|
|
20
|
-
if (existing && existing.
|
|
20
|
+
if (existing && existing.projectId === input.projectId) {
|
|
21
21
|
if (input.baselineCommitId && existing.lastProcessedCommitId === null) {
|
|
22
22
|
updateBindingCursor(store, existing.id, { lastProcessedCommitId: input.baselineCommitId });
|
|
23
23
|
}
|
|
@@ -26,11 +26,11 @@ export async function establishBinding(input) {
|
|
|
26
26
|
await maybeRunTelemetry(input, existing);
|
|
27
27
|
return { binding: getBindingById(store, existing.id), created: false };
|
|
28
28
|
}
|
|
29
|
-
const cloudBinding = await createBinding(input.baseUrl, input.accessToken, input.
|
|
29
|
+
const cloudBinding = await createBinding(input.baseUrl, input.accessToken, input.projectId, { repositoryFingerprint: fingerprint, boundBranch: input.boundBranch, protocolVersion: SYNC_PROTOCOL_VERSION }, bindingIdempotencyKey(input.projectId, input.deviceRef, fingerprint));
|
|
30
30
|
const row = getBindingById(store, cloudBinding.id) ??
|
|
31
31
|
insertBinding(store, {
|
|
32
32
|
id: cloudBinding.id,
|
|
33
|
-
|
|
33
|
+
projectId: input.projectId,
|
|
34
34
|
organizationId: input.organizationId,
|
|
35
35
|
repositoryRoot: repository.repositoryRoot,
|
|
36
36
|
gitDir: repository.gitDir,
|
|
@@ -55,7 +55,7 @@ export async function runRepositoryOnboarding(input) {
|
|
|
55
55
|
store: input.store,
|
|
56
56
|
baseUrl: input.baseUrl,
|
|
57
57
|
accessToken: input.accessToken,
|
|
58
|
-
|
|
58
|
+
projectId: input.projectId,
|
|
59
59
|
organizationId: input.organizationId,
|
|
60
60
|
repository: input.repository,
|
|
61
61
|
boundBranch: input.boundBranch,
|
|
@@ -65,8 +65,8 @@ export async function runRepositoryOnboarding(input) {
|
|
|
65
65
|
runTelemetry: input.runTelemetry,
|
|
66
66
|
});
|
|
67
67
|
const install = await installToolkit(input.store, established.binding.id, input.profile, input.repository.repositoryRoot, input.toolkitDeps ?? {});
|
|
68
|
-
const
|
|
69
|
-
|
|
68
|
+
const projectManifest = await writeProjectManifests(input.repository.repositoryRoot, {
|
|
69
|
+
projectId: input.projectId,
|
|
70
70
|
organizationId: input.organizationId,
|
|
71
71
|
toolkit: {
|
|
72
72
|
name: input.profile.name,
|
|
@@ -77,7 +77,7 @@ export async function runRepositoryOnboarding(input) {
|
|
|
77
77
|
});
|
|
78
78
|
return {
|
|
79
79
|
flow: input.flow,
|
|
80
|
-
|
|
80
|
+
projectId: input.projectId,
|
|
81
81
|
organizationId: input.organizationId,
|
|
82
82
|
bindingId: established.binding.id,
|
|
83
83
|
boundBranch: input.boundBranch,
|
|
@@ -85,7 +85,7 @@ export async function runRepositoryOnboarding(input) {
|
|
|
85
85
|
toolkit: { name: input.profile.name, version: input.profile.version },
|
|
86
86
|
reused: !established.created,
|
|
87
87
|
install,
|
|
88
|
-
|
|
88
|
+
projectManifest,
|
|
89
89
|
noPush: true,
|
|
90
90
|
};
|
|
91
91
|
}
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { resolveRepository, readSymbolicBranch } from "../git-sync/core/repository.js";
|
|
4
4
|
import { readGitOperationState } from "../git-sync/core/operation_state.js";
|
|
5
5
|
import { NotARepositoryError } from "../git-sync/core/git_runner.js";
|
|
6
|
-
import { legacyAppJsonPath,
|
|
6
|
+
import { legacyAppJsonPath, projectJsonPath } from "../project_context.js";
|
|
7
7
|
const SAFE_OS_METADATA = new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".localized"]);
|
|
8
8
|
function significantEntries(dir) {
|
|
9
9
|
let names;
|
|
@@ -15,8 +15,8 @@ function significantEntries(dir) {
|
|
|
15
15
|
}
|
|
16
16
|
return names.filter((name) => name !== ".git" && !SAFE_OS_METADATA.has(name));
|
|
17
17
|
}
|
|
18
|
-
function
|
|
19
|
-
const v2 = read(
|
|
18
|
+
function readMarkerProjectId(root) {
|
|
19
|
+
const v2 = read(projectJsonPath(root));
|
|
20
20
|
if (v2 && v2.schemaVersion === 2) {
|
|
21
21
|
const id = trimmedField(v2.workspaceId);
|
|
22
22
|
if (id)
|
|
@@ -38,7 +38,7 @@ function read(filePath) {
|
|
|
38
38
|
function trimmedField(value) {
|
|
39
39
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
40
40
|
}
|
|
41
|
-
function
|
|
41
|
+
function isNestedUnderProject(root) {
|
|
42
42
|
let dir = path.dirname(root);
|
|
43
43
|
while (dir && dir !== path.dirname(dir)) {
|
|
44
44
|
const nustack = path.join(dir, ".nustack");
|
|
@@ -66,7 +66,7 @@ export function classifyFolder(dir) {
|
|
|
66
66
|
const symbolicBranch = readSymbolicBranch(repository);
|
|
67
67
|
const operation = readGitOperationState(repository);
|
|
68
68
|
const pointerFileGit = lstatSync(gitPath).isFile();
|
|
69
|
-
const
|
|
69
|
+
const markerProjectId = readMarkerProjectId(repository.repositoryRoot);
|
|
70
70
|
return {
|
|
71
71
|
class: "git_worktree",
|
|
72
72
|
entries,
|
|
@@ -77,9 +77,9 @@ export function classifyFolder(dir) {
|
|
|
77
77
|
operationKind: operation.kind,
|
|
78
78
|
operationInProgress: operation.kind !== "normal",
|
|
79
79
|
pointerFileGit,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
hasProjectMarker: markerProjectId !== null,
|
|
81
|
+
markerProjectId,
|
|
82
|
+
nestedUnderProject: isNestedUnderProject(repository.repositoryRoot),
|
|
83
83
|
symbolicBranch,
|
|
84
84
|
},
|
|
85
85
|
};
|
|
@@ -89,8 +89,8 @@ export function classifyFolder(dir) {
|
|
|
89
89
|
return { class: "populated_non_git", entries };
|
|
90
90
|
}
|
|
91
91
|
export function attachRejection(git) {
|
|
92
|
-
if (git.
|
|
93
|
-
return "
|
|
92
|
+
if (git.nestedUnderProject)
|
|
93
|
+
return "nested_project";
|
|
94
94
|
if (git.operationInProgress)
|
|
95
95
|
return "operation_in_progress";
|
|
96
96
|
if (git.detachedHead)
|
|
@@ -35,7 +35,7 @@ export function decideOnboardingApply(repo, targets) {
|
|
|
35
35
|
return { kind: "apply" };
|
|
36
36
|
return { kind: "overwrite_required", divergent: decision.divergent };
|
|
37
37
|
}
|
|
38
|
-
export function composeOnboardingCommit(store, repo, binding, handoffId,
|
|
38
|
+
export function composeOnboardingCommit(store, repo, binding, handoffId, projectId, entries) {
|
|
39
39
|
const oldHead = readHeadCommit(repo);
|
|
40
40
|
const targets = entries.map((entry) => ({
|
|
41
41
|
path: entry.path,
|
|
@@ -49,7 +49,7 @@ export function composeOnboardingCommit(store, repo, binding, handoffId, workspa
|
|
|
49
49
|
if (entry.operation === "upsert" && entry.bytes)
|
|
50
50
|
bytesByPath.set(entry.path, entry.bytes);
|
|
51
51
|
}
|
|
52
|
-
const message = buildCommitMessage(handoffId,
|
|
52
|
+
const message = buildCommitMessage(handoffId, projectId, null);
|
|
53
53
|
const built = buildCommit(repo, oldHead, targets, bytesByPath, message, {
|
|
54
54
|
authorName: NUSTACK_SYNC_IDENTITY.name,
|
|
55
55
|
authorEmail: NUSTACK_SYNC_IDENTITY.email,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { acknowledgeBindingCursor,
|
|
3
|
+
import { acknowledgeBindingCursor, createProjectViaAsk, getProject, latestHandoff, } from "../nustack_client.js";
|
|
4
4
|
import { getBindingById } from "../local_store/bindings.js";
|
|
5
5
|
import { hasManifest, scaffoldManifest } from "../manifest.js";
|
|
6
6
|
import { fetchToolkitProfile, toolkitProfileFromManifest, ToolkitProfileUnavailableError } from "../etna/profile.js";
|
|
@@ -13,7 +13,7 @@ export const MANAGED_ONBOARDING_FILES = [".nustack/workspace.json", "nustack.ser
|
|
|
13
13
|
export async function initFreshFolder(ctx, options) {
|
|
14
14
|
const state = classifyFolder(ctx.cwd);
|
|
15
15
|
if (state.class === "git_worktree") {
|
|
16
|
-
return { ok: false, code: "ALREADY_A_WORKTREE", message: "This folder is already a Git worktree — use `nustack
|
|
16
|
+
return { ok: false, code: "ALREADY_A_WORKTREE", message: "This folder is already a Git worktree — use `nustack project attach` instead of `nustack init`." };
|
|
17
17
|
}
|
|
18
18
|
if (state.class === "populated_non_git" && options.confirmPopulated !== true) {
|
|
19
19
|
return {
|
|
@@ -28,13 +28,13 @@ export async function initFreshFolder(ctx, options) {
|
|
|
28
28
|
}
|
|
29
29
|
const repository = initGitRepository(ctx.cwd);
|
|
30
30
|
const boundBranch = "main";
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
return { ok: false, code: "
|
|
31
|
+
const projectId = options.projectId ?? (await createFreshProject(ctx, state.class, options));
|
|
32
|
+
if (projectId === null) {
|
|
33
|
+
return { ok: false, code: "PROJECT_INITIALIZATION_FAILED", message: "The Project failed to initialize — check the Organization and try again." };
|
|
34
34
|
}
|
|
35
|
-
const profile = await resolveProfile(ctx,
|
|
35
|
+
const profile = await resolveProfile(ctx, projectId);
|
|
36
36
|
if (!profile) {
|
|
37
|
-
return { ok: false, code: "HARNESS_PROFILE_UNAVAILABLE", message: "The
|
|
37
|
+
return { ok: false, code: "HARNESS_PROFILE_UNAVAILABLE", message: "The Project did not project a Toolkit Profile pin — the owning server plan must expose it." };
|
|
38
38
|
}
|
|
39
39
|
const result = await runRepositoryOnboarding({
|
|
40
40
|
store: ctx.store,
|
|
@@ -42,7 +42,7 @@ export async function initFreshFolder(ctx, options) {
|
|
|
42
42
|
accessToken: ctx.accessToken,
|
|
43
43
|
deviceRef: ctx.deviceRef,
|
|
44
44
|
flow: "fresh_folder",
|
|
45
|
-
|
|
45
|
+
projectId,
|
|
46
46
|
organizationId: options.organizationId,
|
|
47
47
|
repository,
|
|
48
48
|
boundBranch,
|
|
@@ -51,7 +51,7 @@ export async function initFreshFolder(ctx, options) {
|
|
|
51
51
|
toolkitDeps: ctx.toolkitDeps,
|
|
52
52
|
runTelemetry: ctx.runTelemetry,
|
|
53
53
|
});
|
|
54
|
-
const seed = await latestHandoff(ctx.baseUrl, ctx.accessToken,
|
|
54
|
+
const seed = await latestHandoff(ctx.baseUrl, ctx.accessToken, projectId);
|
|
55
55
|
const entries = MANAGED_ONBOARDING_FILES.map((rel) => ({
|
|
56
56
|
path: rel,
|
|
57
57
|
operation: "upsert",
|
|
@@ -67,8 +67,8 @@ export async function initFreshFolder(ctx, options) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
const binding = getBindingById(ctx.store, result.bindingId);
|
|
70
|
-
const handoffId = seed?.id ?? `onboarding_seed:${
|
|
71
|
-
const commit = composeOnboardingCommit(ctx.store, repository, binding, handoffId,
|
|
70
|
+
const handoffId = seed?.id ?? `onboarding_seed:${projectId}`;
|
|
71
|
+
const commit = composeOnboardingCommit(ctx.store, repository, binding, handoffId, projectId, entries);
|
|
72
72
|
if (seed) {
|
|
73
73
|
await acknowledgeBindingCursor(ctx.baseUrl, ctx.accessToken, { bindingId: result.bindingId, lastAppliedSequence: 0 }).catch(() => { });
|
|
74
74
|
}
|
|
@@ -77,22 +77,22 @@ export async function initFreshFolder(ctx, options) {
|
|
|
77
77
|
result: { ...result, baselineCommitId: commit.commitId, rootCommitId: commit.commitId, changedPaths: commit.changedPaths, handoffId: seed?.id ?? null },
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
-
async function
|
|
80
|
+
async function createFreshProject(ctx, folderClass, options) {
|
|
81
81
|
void folderClass;
|
|
82
|
-
const outcome = await
|
|
83
|
-
const final = await
|
|
82
|
+
const outcome = await createProjectViaAsk(ctx.baseUrl, ctx.accessToken, options.organizationId, `Create a project named "${options.name}".`);
|
|
83
|
+
const final = await getProject(ctx.baseUrl, ctx.accessToken, outcome.projectId);
|
|
84
84
|
const failed = final.status === "initialization_failed" || final.initStatus === "failed";
|
|
85
|
-
return failed ? null : outcome.
|
|
85
|
+
return failed ? null : outcome.projectId;
|
|
86
86
|
}
|
|
87
|
-
async function resolveProfile(ctx,
|
|
87
|
+
async function resolveProfile(ctx, projectId) {
|
|
88
88
|
try {
|
|
89
|
-
return await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken,
|
|
89
|
+
return await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, projectId);
|
|
90
90
|
}
|
|
91
91
|
catch (error) {
|
|
92
92
|
if (!(error instanceof ToolkitProfileUnavailableError))
|
|
93
93
|
throw error;
|
|
94
94
|
}
|
|
95
|
-
const seed = await latestHandoff(ctx.baseUrl, ctx.accessToken,
|
|
95
|
+
const seed = await latestHandoff(ctx.baseUrl, ctx.accessToken, projectId);
|
|
96
96
|
if (!seed)
|
|
97
97
|
return null;
|
|
98
98
|
const manifest = await fetchHandoffManifest(ctx.baseUrl, ctx.accessToken, { handoffId: seed.id, bindingId: "" }).catch(() => null);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { legacyAppJsonPath, readBindingMarker,
|
|
4
|
+
import { legacyAppJsonPath, readBindingMarker, projectJsonPath } from "../project_context.js";
|
|
5
5
|
import { manifestPath } from "../manifest.js";
|
|
6
6
|
const CURRENT_BINDING_PROTOCOL_VERSION = 1;
|
|
7
7
|
const CURRENT_DOCUMENT_PROTOCOL_VERSION = 1;
|
|
8
|
-
function
|
|
8
|
+
function buildProjectManifest(identity) {
|
|
9
9
|
return {
|
|
10
10
|
schemaVersion: 2,
|
|
11
|
-
workspaceId: identity.
|
|
11
|
+
workspaceId: identity.projectId,
|
|
12
12
|
organizationId: identity.organizationId,
|
|
13
13
|
bindingProtocolVersion: identity.bindingProtocolVersion ?? CURRENT_BINDING_PROTOCOL_VERSION,
|
|
14
14
|
documentProtocolVersion: identity.documentProtocolVersion ?? CURRENT_DOCUMENT_PROTOCOL_VERSION,
|
|
@@ -35,10 +35,10 @@ async function restore(target, prior) {
|
|
|
35
35
|
await writeFile(target, prior, "utf8");
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
export async function
|
|
38
|
+
export async function writeProjectManifests(root, identity, hooks = {}) {
|
|
39
39
|
const servicePath = manifestPath(root);
|
|
40
40
|
if (!existsSync(servicePath)) {
|
|
41
|
-
throw new Error("nustack.service.json is required before writing the
|
|
41
|
+
throw new Error("nustack.service.json is required before writing the Project binding — scaffold it first.");
|
|
42
42
|
}
|
|
43
43
|
const serviceRaw = await readFile(servicePath, "utf8");
|
|
44
44
|
let serviceManifest;
|
|
@@ -50,66 +50,66 @@ export async function writeWorkspaceManifests(root, identity, hooks = {}) {
|
|
|
50
50
|
}
|
|
51
51
|
const nextService = {
|
|
52
52
|
...serviceManifest,
|
|
53
|
-
nustackWorkspace: { v: 2, workspaceId: identity.
|
|
53
|
+
nustackWorkspace: { v: 2, workspaceId: identity.projectId, organizationId: identity.organizationId },
|
|
54
54
|
toolkit: { name: identity.toolkit.name, version: identity.toolkit.version },
|
|
55
55
|
};
|
|
56
56
|
delete nextService.nustackApp;
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
await mkdir(
|
|
60
|
-
const
|
|
57
|
+
const projectManifest = buildProjectManifest(identity);
|
|
58
|
+
const projectDir = path.dirname(projectJsonPath(root));
|
|
59
|
+
await mkdir(projectDir, { recursive: true });
|
|
60
|
+
const projectPrior = await atomicWrite(projectJsonPath(root), JSON.stringify(projectManifest, null, 2) + "\n");
|
|
61
61
|
try {
|
|
62
62
|
if (hooks.beforeServiceWrite)
|
|
63
63
|
await hooks.beforeServiceWrite();
|
|
64
64
|
await atomicWrite(servicePath, JSON.stringify(nextService, null, 4) + "\n");
|
|
65
65
|
}
|
|
66
66
|
catch (error) {
|
|
67
|
-
await restore(
|
|
67
|
+
await restore(projectJsonPath(root), projectPrior);
|
|
68
68
|
throw error;
|
|
69
69
|
}
|
|
70
|
-
return
|
|
70
|
+
return projectManifest;
|
|
71
71
|
}
|
|
72
72
|
function toolkitEqual(a, b) {
|
|
73
73
|
return a !== null && b !== null && a.name === b.name && a.version === b.version;
|
|
74
74
|
}
|
|
75
|
-
export async function
|
|
76
|
-
const
|
|
75
|
+
export async function readProjectManifests(root) {
|
|
76
|
+
const projectManifest = await readProjectJson(root);
|
|
77
77
|
const service = await readServiceBlocks(root);
|
|
78
78
|
let localDiagnosis;
|
|
79
|
-
if (!
|
|
79
|
+
if (!projectManifest || !service.toolkit) {
|
|
80
80
|
localDiagnosis = "missing";
|
|
81
81
|
}
|
|
82
82
|
else {
|
|
83
|
-
localDiagnosis = toolkitEqual(
|
|
83
|
+
localDiagnosis = toolkitEqual(projectManifest.toolkit, service.toolkit) ? "aligned" : "divergent";
|
|
84
84
|
}
|
|
85
85
|
return {
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
projectManifest,
|
|
87
|
+
serviceProjectBinding: service.binding,
|
|
88
88
|
serviceToolkit: service.toolkit,
|
|
89
89
|
localDiagnosis,
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
|
-
export function diagnoseManifests(cloud,
|
|
93
|
-
if (!
|
|
92
|
+
export function diagnoseManifests(cloud, projectJson, serviceJson) {
|
|
93
|
+
if (!projectJson || !serviceJson)
|
|
94
94
|
return "missing";
|
|
95
95
|
if (!cloud) {
|
|
96
|
-
return toolkitEqual(
|
|
96
|
+
return toolkitEqual(projectJson, serviceJson) ? "aligned" : "divergent";
|
|
97
97
|
}
|
|
98
|
-
const
|
|
98
|
+
const projectOk = toolkitEqual(cloud, projectJson);
|
|
99
99
|
const serviceOk = toolkitEqual(cloud, serviceJson);
|
|
100
|
-
if (
|
|
100
|
+
if (projectOk && serviceOk)
|
|
101
101
|
return "aligned";
|
|
102
|
-
if (!
|
|
103
|
-
return "
|
|
104
|
-
if (
|
|
102
|
+
if (!projectOk && serviceOk)
|
|
103
|
+
return "project_json_stale";
|
|
104
|
+
if (projectOk && !serviceOk)
|
|
105
105
|
return "service_json_stale";
|
|
106
106
|
return "divergent";
|
|
107
107
|
}
|
|
108
|
-
async function
|
|
108
|
+
async function readProjectJson(root) {
|
|
109
109
|
const marker = await readBindingMarker(root);
|
|
110
110
|
if (!marker)
|
|
111
111
|
return null;
|
|
112
|
-
const filePath = marker.version === 2 ?
|
|
112
|
+
const filePath = marker.version === 2 ? projectJsonPath(root) : legacyAppJsonPath(root);
|
|
113
113
|
if (!existsSync(filePath))
|
|
114
114
|
return null;
|
|
115
115
|
try {
|
|
@@ -121,7 +121,7 @@ async function readWorkspaceJson(root) {
|
|
|
121
121
|
return null;
|
|
122
122
|
return {
|
|
123
123
|
schemaVersion: 2,
|
|
124
|
-
workspaceId: marker.
|
|
124
|
+
workspaceId: marker.projectId,
|
|
125
125
|
organizationId: marker.organizationId,
|
|
126
126
|
bindingProtocolVersion: typeof raw.bindingProtocolVersion === "number" ? raw.bindingProtocolVersion : 1,
|
|
127
127
|
documentProtocolVersion: typeof raw.documentProtocolVersion === "number" ? raw.documentProtocolVersion : 1,
|
|
@@ -146,9 +146,9 @@ async function readServiceBlocks(root) {
|
|
|
146
146
|
const v2 = manifest.nustackWorkspace;
|
|
147
147
|
const v1 = manifest.nustackApp;
|
|
148
148
|
const binding = typeof v2?.workspaceId === "string" && typeof v2?.organizationId === "string"
|
|
149
|
-
? {
|
|
149
|
+
? { projectId: v2.workspaceId, organizationId: v2.organizationId }
|
|
150
150
|
: typeof v1?.appId === "string" && typeof v1?.workspaceId === "string"
|
|
151
|
-
? {
|
|
151
|
+
? { projectId: v1.appId, organizationId: v1.workspaceId }
|
|
152
152
|
: null;
|
|
153
153
|
const toolkit = typeof manifest.toolkit?.name === "string" && typeof manifest.toolkit?.version === "string"
|
|
154
154
|
? { name: manifest.toolkit.name, version: manifest.toolkit.version }
|
|
@@ -42,9 +42,9 @@ export async function webFirstAttach(ctx, options) {
|
|
|
42
42
|
if (!hasManifest(repository.repositoryRoot)) {
|
|
43
43
|
await scaffoldManifest(repository.repositoryRoot, deriveScaffoldSlug(repository.repositoryRoot));
|
|
44
44
|
}
|
|
45
|
-
const profile = await resolveProfile(ctx, options.
|
|
45
|
+
const profile = await resolveProfile(ctx, options.projectId);
|
|
46
46
|
if (!profile)
|
|
47
|
-
return { ok: false, code: "HARNESS_PROFILE_UNAVAILABLE", message: "The
|
|
47
|
+
return { ok: false, code: "HARNESS_PROFILE_UNAVAILABLE", message: "The Project did not project a Toolkit Profile pin — the owning server plan must expose it." };
|
|
48
48
|
const baselineCommitId = isFresh ? null : readHeadCommit(repository);
|
|
49
49
|
const result = await runRepositoryOnboarding({
|
|
50
50
|
store: ctx.store,
|
|
@@ -52,7 +52,7 @@ export async function webFirstAttach(ctx, options) {
|
|
|
52
52
|
accessToken: ctx.accessToken,
|
|
53
53
|
deviceRef: ctx.deviceRef,
|
|
54
54
|
flow: "web_first",
|
|
55
|
-
|
|
55
|
+
projectId: options.projectId,
|
|
56
56
|
organizationId: options.organizationId,
|
|
57
57
|
repository,
|
|
58
58
|
boundBranch,
|
|
@@ -62,10 +62,10 @@ export async function webFirstAttach(ctx, options) {
|
|
|
62
62
|
runTelemetry: ctx.runTelemetry,
|
|
63
63
|
});
|
|
64
64
|
const binding = getBindingById(ctx.store, result.bindingId);
|
|
65
|
-
const latest = await latestHandoff(ctx.baseUrl, ctx.accessToken, options.
|
|
65
|
+
const latest = await latestHandoff(ctx.baseUrl, ctx.accessToken, options.projectId).catch(() => null);
|
|
66
66
|
if (!latest) {
|
|
67
67
|
const applied = isFresh
|
|
68
|
-
? composeOnboardingCommit(ctx.store, repository, binding, `onboarding_seed:${options.
|
|
68
|
+
? composeOnboardingCommit(ctx.store, repository, binding, `onboarding_seed:${options.projectId}`, options.projectId, managedEntries(repository.repositoryRoot))
|
|
69
69
|
: null;
|
|
70
70
|
return { ok: true, result: baseResult(result, applied, null, false, []) };
|
|
71
71
|
}
|
|
@@ -86,7 +86,7 @@ export async function webFirstAttach(ctx, options) {
|
|
|
86
86
|
entries.push({ path: target.path, operation: target.operation, bytes: bytesByPath.get(target.path) ?? null });
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
-
const applied = entries.length > 0 ? composeOnboardingCommit(ctx.store, repository, binding, latest.id, options.
|
|
89
|
+
const applied = entries.length > 0 ? composeOnboardingCommit(ctx.store, repository, binding, latest.id, options.projectId, entries) : null;
|
|
90
90
|
await acknowledgeBindingCursor(ctx.baseUrl, ctx.accessToken, { bindingId: result.bindingId, lastAppliedSequence: 0 }).catch(() => { });
|
|
91
91
|
return { ok: true, result: baseResult(result, applied, latest.id, false, []) };
|
|
92
92
|
}
|
|
@@ -104,27 +104,27 @@ function baseResult(result, applied, handoffId, overwriteRequired, divergent) {
|
|
|
104
104
|
divergent,
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
async function resolveProfile(ctx,
|
|
107
|
+
async function resolveProfile(ctx, projectId) {
|
|
108
108
|
try {
|
|
109
|
-
return await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken,
|
|
109
|
+
return await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, projectId);
|
|
110
110
|
}
|
|
111
111
|
catch (error) {
|
|
112
112
|
if (!(error instanceof ToolkitProfileUnavailableError))
|
|
113
113
|
throw error;
|
|
114
114
|
}
|
|
115
|
-
const seed = await latestHandoff(ctx.baseUrl, ctx.accessToken,
|
|
115
|
+
const seed = await latestHandoff(ctx.baseUrl, ctx.accessToken, projectId).catch(() => null);
|
|
116
116
|
if (!seed)
|
|
117
117
|
return null;
|
|
118
118
|
const manifest = await fetchHandoffManifest(ctx.baseUrl, ctx.accessToken, { handoffId: seed.id, bindingId: "" }).catch(() => null);
|
|
119
119
|
return manifest ? toolkitProfileFromManifest(manifest) : null;
|
|
120
120
|
}
|
|
121
121
|
function deriveScaffoldSlug(root) {
|
|
122
|
-
return path.basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "
|
|
122
|
+
return path.basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
123
123
|
}
|
|
124
124
|
function rejectionMessage(rejection) {
|
|
125
125
|
switch (rejection) {
|
|
126
|
-
case "
|
|
127
|
-
return "This repository is nested inside another NuStack
|
|
126
|
+
case "nested_project":
|
|
127
|
+
return "This repository is nested inside another NuStack Project — one Project has one logical repository root (F-014).";
|
|
128
128
|
case "operation_in_progress":
|
|
129
129
|
return "A merge, rebase, cherry-pick, or revert is in progress — finish or abort it, then re-attach.";
|
|
130
130
|
case "detached_head":
|
|
@@ -4,22 +4,22 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import { readManagedValues } from "./env_file.js";
|
|
5
5
|
import { manifestPath } from "./manifest.js";
|
|
6
6
|
import { assertSafeOpaqueId } from "./guards.js";
|
|
7
|
-
const
|
|
8
|
-
export function
|
|
7
|
+
const NO_PROJECT_MESSAGE = "No Project id — run inside a bound repository (`.nustack/workspace.json`), pass `--project <id>`, add an `nustackWorkspace.projectId` to nustack.service.json, or set NUSTACK_WORKSPACE_ID in the managed .env block.";
|
|
8
|
+
export function projectJsonPath(cwd) {
|
|
9
9
|
return path.join(cwd, ".nustack", "workspace.json");
|
|
10
10
|
}
|
|
11
11
|
export function legacyAppJsonPath(cwd) {
|
|
12
12
|
return path.join(cwd, ".nustack", "app.json");
|
|
13
13
|
}
|
|
14
14
|
export async function readBindingMarker(cwd) {
|
|
15
|
-
const v2Path =
|
|
15
|
+
const v2Path = projectJsonPath(cwd);
|
|
16
16
|
if (existsSync(v2Path)) {
|
|
17
17
|
try {
|
|
18
18
|
const marker = JSON.parse(await readFile(v2Path, "utf8"));
|
|
19
19
|
if (marker.schemaVersion === 2) {
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
return {
|
|
20
|
+
const projectId = trimmed(marker.workspaceId);
|
|
21
|
+
if (projectId) {
|
|
22
|
+
return { projectId, organizationId: trimmed(marker.organizationId), version: 2 };
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -33,10 +33,10 @@ export async function readBindingMarker(cwd) {
|
|
|
33
33
|
const marker = JSON.parse(await readFile(v1Path, "utf8"));
|
|
34
34
|
if (marker.schemaVersion !== undefined && marker.schemaVersion !== 1)
|
|
35
35
|
return null;
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
36
|
+
const projectId = trimmed(marker.appId);
|
|
37
|
+
if (!projectId)
|
|
38
38
|
return null;
|
|
39
|
-
return {
|
|
39
|
+
return { projectId, organizationId: trimmed(marker.workspaceId), version: 1 };
|
|
40
40
|
}
|
|
41
41
|
catch {
|
|
42
42
|
return null;
|
|
@@ -45,10 +45,10 @@ export async function readBindingMarker(cwd) {
|
|
|
45
45
|
function trimmed(value) {
|
|
46
46
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
47
47
|
}
|
|
48
|
-
export async function
|
|
49
|
-
return (await readBindingMarker(cwd))?.
|
|
48
|
+
export async function readProjectJsonProjectId(cwd) {
|
|
49
|
+
return (await readBindingMarker(cwd))?.projectId ?? null;
|
|
50
50
|
}
|
|
51
|
-
async function
|
|
51
|
+
async function readManifestProjectId(cwd) {
|
|
52
52
|
const filePath = manifestPath(cwd);
|
|
53
53
|
if (!existsSync(filePath))
|
|
54
54
|
return null;
|
|
@@ -60,24 +60,24 @@ async function readManifestWorkspaceId(cwd) {
|
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
-
async function
|
|
63
|
+
async function readEnvProjectId(cwd) {
|
|
64
64
|
const managed = await readManagedValues(path.join(cwd, ".env"));
|
|
65
|
-
const
|
|
66
|
-
return
|
|
65
|
+
const projectId = managed.NUSTACK_WORKSPACE_ID?.trim();
|
|
66
|
+
return projectId ? projectId : null;
|
|
67
67
|
}
|
|
68
|
-
export async function
|
|
69
|
-
const flag = options.
|
|
70
|
-
const
|
|
71
|
-
const candidate =
|
|
72
|
-
? { value:
|
|
68
|
+
export async function resolveProjectId(cwd, options) {
|
|
69
|
+
const flag = options.project?.trim();
|
|
70
|
+
const projectJsonId = await readProjectJsonProjectId(cwd);
|
|
71
|
+
const candidate = projectJsonId
|
|
72
|
+
? { value: projectJsonId, source: "project_json" }
|
|
73
73
|
: flag
|
|
74
74
|
? { value: flag, source: "flag" }
|
|
75
|
-
: (await
|
|
76
|
-
(await
|
|
75
|
+
: (await readManifestProjectId(cwd).then((id) => (id ? { value: id, source: "manifest" } : null))) ??
|
|
76
|
+
(await readEnvProjectId(cwd).then((id) => (id ? { value: id, source: "env" } : null)));
|
|
77
77
|
if (!candidate)
|
|
78
|
-
return { ok: false, message:
|
|
78
|
+
return { ok: false, message: NO_PROJECT_MESSAGE };
|
|
79
79
|
try {
|
|
80
|
-
return { ok: true,
|
|
80
|
+
return { ok: true, projectId: assertSafeOpaqueId("The Project id", candidate.value), source: candidate.source };
|
|
81
81
|
}
|
|
82
82
|
catch (error) {
|
|
83
83
|
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
@@ -41,7 +41,7 @@ export function makeReviewClient(baseUrl, accessToken) {
|
|
|
41
41
|
method: "POST",
|
|
42
42
|
body: JSON.stringify({
|
|
43
43
|
kind: "blocking_approval",
|
|
44
|
-
|
|
44
|
+
projectId: input.projectId,
|
|
45
45
|
sessionId: input.sessionId,
|
|
46
46
|
subject: input.subject,
|
|
47
47
|
...(input.answerableByUserId ? { answerableByUserId: input.answerableByUserId } : {}),
|
|
@@ -44,7 +44,7 @@ function toTelemetryBinding(binding, deviceRef, userId) {
|
|
|
44
44
|
bindingId: binding.id,
|
|
45
45
|
repoRoot: binding.repositoryRoot,
|
|
46
46
|
organizationId: binding.organizationId,
|
|
47
|
-
|
|
47
|
+
projectId: binding.projectId,
|
|
48
48
|
deviceRef,
|
|
49
49
|
userId,
|
|
50
50
|
};
|
|
@@ -122,7 +122,7 @@ export async function ensureBindingTelemetry(opts) {
|
|
|
122
122
|
}
|
|
123
123
|
let brokerFailure = null;
|
|
124
124
|
const credential = await fetchBrokeredCredential(baseUrl, auth.accessToken, {
|
|
125
|
-
|
|
125
|
+
projectId: binding.projectId,
|
|
126
126
|
service: TELEMETRY_SERVICE,
|
|
127
127
|
}).catch((error) => {
|
|
128
128
|
brokerFailure = error instanceof Error ? error.message : String(error);
|