Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3dabc47
Add beta package workspace commands
promeris Aug 19, 2026
41320c9
Raise workspace command coverage
promeris Aug 19, 2026
c36ae12
Fix workspace checkout and push state
promeris Aug 19, 2026
63730f2
Reject duplicate workspace move targets
promeris Aug 19, 2026
f0d5758
Support case-only workspace moves
promeris Aug 19, 2026
1c363c3
Use boolean workspace target lookup
promeris Aug 19, 2026
d52503a
Infer package workspace moves
promeris Aug 19, 2026
f04719a
Validate package workspace path identity
promeris Aug 19, 2026
fe9bd57
Add workspace clone and pull commands
promeris Aug 19, 2026
5bd31c1
Keep workspace synchronization state local
promeris Aug 19, 2026
903ff4e
Preserve workspace metadata backups
promeris Aug 19, 2026
42d201a
Distinguish workspace deletion and addition
promeris Aug 19, 2026
e98a682
Address workspace quality checks
promeris Aug 19, 2026
bd631d0
Cover workspace command dispatch
promeris Aug 19, 2026
774d3f4
Fix workspace refresh and reconciled moves
promeris Aug 19, 2026
9c16030
Handle ambiguous workspace moves
promeris Aug 19, 2026
2098478
Harden workspace push recovery
promeris Aug 19, 2026
d1c029a
Accept Windows workspace gitignore
promeris Aug 19, 2026
fee919c
Handle case-only workspace moves
promeris Aug 19, 2026
60bc926
Align workspace move path matching
promeris Aug 19, 2026
e08e56a
Recover workspace after refresh failure
promeris Aug 19, 2026
8551a17
Add incremental workspace push
promeris Aug 20, 2026
2d29132
Add branch-aware package workspaces
promeris Aug 20, 2026
df81624
Fix workspace quality gate findings
promeris Aug 20, 2026
f27f319
Fix workspace synchronization edge cases
promeris Aug 20, 2026
7041183
Fix incremental workspace push retries
promeris Aug 20, 2026
133da0e
Add incremental workspace pull
promeris Aug 20, 2026
b9e0daa
Clarify empty path-selected pushes
promeris Aug 20, 2026
cd37475
Resolve workspace static analysis findings
promeris Aug 20, 2026
9bd53f8
Derive workspace paths from node metadata
promeris Aug 20, 2026
ad805a1
Harden workspace synchronization recovery
promeris Aug 20, 2026
5b70975
Fix package-root path projection
promeris Aug 20, 2026
c43514e
Continue pull after Git advances
promeris Aug 20, 2026
1ed72db
Handle pull updates below moved folders
promeris Aug 20, 2026
07c91df
Rename workspace metadata root to .package
promeris Aug 20, 2026
bcfce5b
Clarify workspace extension ownership
promeris Aug 20, 2026
fbc541e
Harden beta workspace synchronization
promeris Aug 20, 2026
2b424e4
Reduce workspace pull complexity
promeris Aug 20, 2026
35692cf
Update beta workspace manifest handling
promeris Aug 21, 2026
1ab556b
Require manifest validators for folders
promeris Aug 21, 2026
1c737c0
Fix Markdown workspace path projection
promeris Aug 21, 2026
fd41d9c
Make workspace manifests lightweight
promeris Aug 21, 2026
d37b3fa
Reduce workspace synchronization complexity
promeris Aug 21, 2026
26f9fe9
Add empty folder workspace push
promeris Aug 24, 2026
4437b76
Require Asset Type for workspace file writes
promeris Aug 24, 2026
edb96d5
Remove archive-backed workspace operations
promeris Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
/src/core @celonis/astro
/src/commands/configuration-management/ @celonis/astro
/tests/commands/configuration-management/ @celonis/astro
/src/commands/workspace/ @celonis/astro
/tests/commands/workspace/ @celonis/astro
/src/commands/t2tc/ @celonis/astro
/tests/commands/t2tc/ @celonis/astro
/src/commands/profile/ @celonis/astro
Expand Down
97 changes: 97 additions & 0 deletions src/commands/workspace/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Command, OptionValues } from "commander";
import { Context } from "../../core/command/cli-context";
import { Configurator, IModule } from "../../core/command/module-handler";
import { WorkspaceService } from "./workspace.service";

async function runWorkspaceCommand<T>(action: () => Promise<T> | T): Promise<void> {
try {
await action();
} catch (error) {
process.exitCode = 1;
throw error;
}
}

class Module extends IModule {
public register(context: Context, configurator: Configurator): void {
const workspace = configurator.command("workspace").beta().description("Manage a package workspace.");

workspace
.command("clone <projectKey> [directory]")
.beta()
.description("Clone a package workspace.")
.option("--branch <branch>", "Clone a branch")
.action(this.clone);

workspace
.command("checkout [branch]")
.beta()
.description("Select a package branch.")
.option("-b, --create <branch>", "Create and select a branch")
.option("--discard", "Discard local workspace changes", false)
.option("--link-git", "Map the current Git branch", false)
.action(this.checkout);

workspace.command("pull [paths...]").beta().description("Pull remote changes.").action(this.pull);

workspace.command("status [directory]").beta().description("Show local changes.").action(this.status);

workspace
.command("push [paths...]")
.beta()
.description("Push local changes.")
.option("--asset-type <assetType>", "Asset Type for new files")
.action(this.push);

workspace
.command("move <source> <target>")
.beta()
.description("Move a tracked file to another parent.")
.option("--record", "Record an existing move", false)
.action(this.move);
}

private async clone(context: Context, command: Command, options: OptionValues): Promise<void> {
await runWorkspaceCommand(() =>
new WorkspaceService(context).clone(command.args[0], command.args[1], { branch: options.branch })
);
}

private async checkout(context: Context, command: Command, options: OptionValues): Promise<void> {
await runWorkspaceCommand(async () => {
const branch = options.create || command.args[0];
if (!branch || (options.create && command.args[0])) {
throw new Error("Provide one branch name or use -b <branch>.");
}
await new WorkspaceService(context).checkout(branch, {
create: Boolean(options.create),
discard: options.discard,
linkGit: options.linkGit,
});
});
}

private async pull(context: Context, command: Command): Promise<void> {
await runWorkspaceCommand(() => new WorkspaceService(context).pull(command.args));
}

private async status(context: Context, command: Command): Promise<void> {
await runWorkspaceCommand(() => new WorkspaceService(context).statusWithGit(command.args[0]));
}

private async push(context: Context, command: Command, options: OptionValues): Promise<void> {
await runWorkspaceCommand(() =>
new WorkspaceService(context).push(command.args, {
assetType: options.assetType,
})
);
}

private async move(context: Context, command: Command, options: OptionValues): Promise<void> {
await runWorkspaceCommand(() =>
new WorkspaceService(context).move(command.args[0], command.args[1], options.record)
);
}
}

export = Module;
112 changes: 112 additions & 0 deletions src/commands/workspace/workspace-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Context } from "../../core/command/cli-context";
import { GracefulError } from "../../core/utils/logger";
import { NodeFileWriteResponse, WorkspaceBranch, WorkspaceManifest } from "./workspace.models";

export class WorkspaceApi {
constructor(private readonly context: Context) {}

public async readFile(packageKey: string, filePath: string): Promise<{ body: Buffer; eTag: string }> {
const response = await this.context.httpClient.getFileWithHeaders(this.fileUrl(packageKey, filePath));
const eTag = response.headers.etag;
if (typeof eTag !== "string") {
throw new GracefulError(`File response does not contain an ETag: ${filePath}`);
}
return { body: response.data, eTag };
}

public async manifest(
packageKey: string,
ifNoneMatch?: string
): Promise<{ manifest?: WorkspaceManifest; eTag: string; notModified: boolean }> {
const response = await this.context.httpClient.getFileWithHeaders(
`/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files`,
ifNoneMatch ? { "If-None-Match": ifNoneMatch } : {},
[200, 304]
);
const eTag = response.headers.etag;
if (typeof eTag !== "string" || !eTag) {
throw new GracefulError("Workspace manifest response does not contain a package ETag.");
}
if (response.status === 304) {
return { eTag, notModified: true };
}
try {
return {
manifest: JSON.parse(response.data.toString("utf-8")) as WorkspaceManifest,
eTag,
notModified: false,
};
} catch (error) {
const failure = new GracefulError("Workspace manifest response is invalid JSON.");
failure.cause = error;
throw failure;
}
}

public putFile(
packageKey: string,
filePath: string,
assetType: string,
body: Buffer,
contentType: string,
headers: Record<string, string>
): Promise<NodeFileWriteResponse> {
return this.context.httpClient.putFile(
`${this.fileUrl(packageKey, filePath)}?assetType=${encodeURIComponent(assetType)}`,
body,
contentType,
undefined,
headers
);
}

public createFolder(packageKey: string, folderPath: string): Promise<NodeFileWriteResponse> {
return this.context.httpClient.putFile(
this.folderUrl(packageKey, folderPath),
Buffer.alloc(0),
"application/octet-stream",
undefined,
{ "If-None-Match": "*" }
);
}

public moveFile(
packageKey: string,
sourcePath: string,
targetPath: string,
eTag: string
): Promise<NodeFileWriteResponse> {
return this.context.httpClient.patch(
this.fileUrl(packageKey, sourcePath),
{ targetPath },
{ "If-Match": eTag }
);
}

public deleteFile(packageKey: string, filePath: string, eTag: string): Promise<void> {
return this.context.httpClient.delete(this.fileUrl(packageKey, filePath), { "If-Match": eTag });
}

public createBranch(packageKey: string, branchKey: string): Promise<WorkspaceBranch> {
return this.context.httpClient.post(`/pacman/api/core/packages/${encodeURIComponent(packageKey)}/branches`, {
branchKey,
version: "STAGING",
});
}

private fileUrl(packageKey: string, filePath: string): string {
return this.pathUrl(packageKey, "files", filePath);
}

private folderUrl(packageKey: string, folderPath: string): string {
return this.pathUrl(packageKey, "folders", folderPath);
}

private pathUrl(packageKey: string, collection: string, entryPath: string): string {
const encodedPath = entryPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/${collection}/${encodedPath}`;
}
}
Loading
Loading