@platforma-sdk/pl-cli 0.2.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.
@@ -0,0 +1,80 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import { field, toGlobalResourceId } from "@milaboratories/pl-client";
3
+ import { ProjectMetaKey } from "@milaboratories/pl-middle-layer";
4
+ import { randomUUID } from "node:crypto";
5
+ import { PlCommand } from "../../base_command";
6
+ import {
7
+ resolveProject,
8
+ deduplicateName,
9
+ duplicateProject,
10
+ getExistingLabelsInTx,
11
+ } from "../../project_ops";
12
+ import { outputJson, outputText } from "../../output";
13
+
14
+ export default class ProjectDuplicate extends PlCommand {
15
+ static override description =
16
+ "Duplicate a project within the same user. Auto-renames on collision by default.";
17
+
18
+ static override args = {
19
+ project: Args.string({
20
+ description: "Project ID or label",
21
+ required: true,
22
+ }),
23
+ };
24
+
25
+ static override flags = {
26
+ ...PlCommand.baseFlags,
27
+ name: Flags.string({
28
+ char: "n",
29
+ summary: "Name for the duplicate",
30
+ helpValue: "<name>",
31
+ }),
32
+ "auto-rename": Flags.boolean({
33
+ summary: "Auto-rename on collision (default: true)",
34
+ default: true,
35
+ allowNo: true,
36
+ }),
37
+ };
38
+
39
+ public async run(): Promise<void> {
40
+ const { args, flags } = await this.parse(ProjectDuplicate);
41
+ const { pl, projectListRid } = await this.connect(flags);
42
+
43
+ const { rid: sourceRid } = await resolveProject(pl, projectListRid, args.project);
44
+
45
+ const newId = randomUUID();
46
+
47
+ const newRid = await pl.withWriteTx("duplicateProject", async (tx) => {
48
+ const sourceMetaStr = await tx.getKValueString(sourceRid, ProjectMetaKey);
49
+ const sourceMeta = JSON.parse(sourceMetaStr);
50
+ const sourceLabel: string = sourceMeta.label;
51
+
52
+ const existingLabels = await getExistingLabelsInTx(tx, projectListRid);
53
+
54
+ // Compute new label
55
+ let newLabel: string;
56
+ if (flags.name) {
57
+ if (!flags["auto-rename"] && existingLabels.includes(flags.name)) {
58
+ throw new Error(`Project name "${flags.name}" already exists.`);
59
+ }
60
+ newLabel = existingLabels.includes(flags.name)
61
+ ? deduplicateName(flags.name, existingLabels)
62
+ : flags.name;
63
+ } else {
64
+ newLabel = deduplicateName(sourceLabel, existingLabels);
65
+ }
66
+
67
+ const newPrj = await duplicateProject(tx, sourceRid, { label: newLabel });
68
+ tx.createField(field(projectListRid, newId), "Dynamic", newPrj);
69
+ await tx.commit();
70
+
71
+ return { rid: await toGlobalResourceId(newPrj), label: newLabel };
72
+ });
73
+
74
+ if (flags.format === "json") {
75
+ outputJson({ id: newId, rid: String(newRid.rid), label: newRid.label });
76
+ } else {
77
+ outputText(`Duplicated project as "${newRid.label}" (id: ${newId})`);
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,52 @@
1
+ import { Args } from "@oclif/core";
2
+ import { PlCommand } from "../../base_command";
3
+ import { resolveProject, getProjectInfo } from "../../project_ops";
4
+ import { formatDate, outputJson, outputText } from "../../output";
5
+
6
+ export default class ProjectInfo extends PlCommand {
7
+ static override description = "Show detailed information about a project.";
8
+
9
+ static override args = {
10
+ project: Args.string({
11
+ description: "Project ID or label",
12
+ required: true,
13
+ }),
14
+ };
15
+
16
+ static override flags = {
17
+ ...PlCommand.baseFlags,
18
+ };
19
+
20
+ public async run(): Promise<void> {
21
+ const { args, flags } = await this.parse(ProjectInfo);
22
+ const { pl, projectListRid } = await this.connect(flags);
23
+ const { id } = await resolveProject(pl, projectListRid, args.project);
24
+ const info = await getProjectInfo(pl, projectListRid, id);
25
+
26
+ if (flags.format === "json") {
27
+ outputJson({
28
+ id: info.id,
29
+ rid: info.rid,
30
+ label: info.label,
31
+ schemaVersion: info.schemaVersion,
32
+ blockCount: info.blockCount,
33
+ blockIds: info.blockIds,
34
+ created: info.created.toISOString(),
35
+ lastModified: info.lastModified.toISOString(),
36
+ });
37
+ } else {
38
+ outputText(`Project: ${info.label}`);
39
+ outputText(`ID: ${info.id}`);
40
+ outputText(`RID: ${info.rid}`);
41
+ outputText(`Schema: ${info.schemaVersion ?? "(unknown)"}`);
42
+ outputText(`Blocks: ${info.blockCount}`);
43
+ if (info.blockIds.length > 0) {
44
+ for (const bid of info.blockIds) {
45
+ outputText(` - ${bid}`);
46
+ }
47
+ }
48
+ outputText(`Created: ${formatDate(info.created)}`);
49
+ outputText(`Last Modified: ${formatDate(info.lastModified)}`);
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,47 @@
1
+ import { PlCommand } from "../../base_command";
2
+ import { listProjects } from "../../project_ops";
3
+ import { formatTable, formatDate, outputJson, outputText } from "../../output";
4
+
5
+ export default class ProjectList extends PlCommand {
6
+ static override description = "List all projects for the authenticated user.";
7
+
8
+ static override flags = {
9
+ ...PlCommand.baseFlags,
10
+ };
11
+
12
+ public async run(): Promise<void> {
13
+ const { flags } = await this.parse(ProjectList);
14
+ const { pl, projectListRid } = await this.connect(flags);
15
+ const projects = await listProjects(pl, projectListRid);
16
+
17
+ if (flags.format === "json") {
18
+ outputJson(
19
+ projects.map((p) => ({
20
+ id: p.id,
21
+ rid: p.rid,
22
+ label: p.label,
23
+ created: p.created.toISOString(),
24
+ lastModified: p.lastModified.toISOString(),
25
+ })),
26
+ );
27
+ } else {
28
+ if (projects.length === 0) {
29
+ outputText("No projects found.");
30
+ return;
31
+ }
32
+
33
+ outputText(
34
+ formatTable(
35
+ ["ID", "LABEL", "CREATED", "LAST MODIFIED"],
36
+ projects.map((p) => [
37
+ p.id.substring(0, 8) + "...",
38
+ p.label,
39
+ formatDate(p.created),
40
+ formatDate(p.lastModified),
41
+ ]),
42
+ ),
43
+ );
44
+ outputText(`\n${projects.length} project(s)`);
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,39 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import { PlCommand } from "../../base_command";
3
+ import { resolveProject, renameProject } from "../../project_ops";
4
+ import { outputJson, outputText } from "../../output";
5
+
6
+ export default class ProjectRename extends PlCommand {
7
+ static override description = "Rename a project.";
8
+
9
+ static override args = {
10
+ project: Args.string({
11
+ description: "Project ID or label",
12
+ required: true,
13
+ }),
14
+ };
15
+
16
+ static override flags = {
17
+ ...PlCommand.baseFlags,
18
+ name: Flags.string({
19
+ char: "n",
20
+ summary: "New name for the project",
21
+ helpValue: "<name>",
22
+ required: true,
23
+ }),
24
+ };
25
+
26
+ public async run(): Promise<void> {
27
+ const { args, flags } = await this.parse(ProjectRename);
28
+ const { pl, projectListRid } = await this.connect(flags);
29
+ const { id, rid } = await resolveProject(pl, projectListRid, args.project);
30
+
31
+ await renameProject(pl, rid, flags.name);
32
+
33
+ if (flags.format === "json") {
34
+ outputJson({ id, rid: String(rid), label: flags.name });
35
+ } else {
36
+ outputText(`Renamed project to "${flags.name}"`);
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,60 @@
1
+ import { Flags } from "@oclif/core";
2
+
3
+ export const GlobalFlags = {
4
+ address: Flags.string({
5
+ char: "a",
6
+ summary: "Platforma server address",
7
+ helpValue: "<url>",
8
+ env: "PL_ADDRESS",
9
+ required: true,
10
+ }),
11
+ format: Flags.string({
12
+ char: "f",
13
+ summary: "Output format",
14
+ options: ["text", "json"],
15
+ default: "text",
16
+ }),
17
+ };
18
+
19
+ export const UserAuthFlags = {
20
+ user: Flags.string({
21
+ char: "u",
22
+ summary: "Username for authentication",
23
+ env: "PL_USER",
24
+ }),
25
+ password: Flags.string({
26
+ char: "p",
27
+ summary: "Password for authentication",
28
+ env: "PL_PASSWORD",
29
+ }),
30
+ };
31
+
32
+ /** Admin credentials only (for purely admin commands like copy-project). */
33
+ export const AdminAuthFlags = {
34
+ "admin-user": Flags.string({
35
+ summary: "Admin/controller username",
36
+ env: "PL_ADMIN_USER",
37
+ required: true,
38
+ }),
39
+ "admin-password": Flags.string({
40
+ summary: "Admin/controller password",
41
+ env: "PL_ADMIN_PASSWORD",
42
+ required: true,
43
+ }),
44
+ };
45
+
46
+ /** Admin credentials + target user (for regular commands that can optionally operate on another user). */
47
+ export const AdminTargetFlags = {
48
+ "admin-user": Flags.string({
49
+ summary: "Admin/controller username (enables admin mode)",
50
+ env: "PL_ADMIN_USER",
51
+ }),
52
+ "admin-password": Flags.string({
53
+ summary: "Admin/controller password",
54
+ env: "PL_ADMIN_PASSWORD",
55
+ }),
56
+ "target-user": Flags.string({
57
+ summary: "Operate on this user's data (requires admin credentials)",
58
+ env: "PL_TARGET_USER",
59
+ }),
60
+ };
@@ -0,0 +1,54 @@
1
+ import {
2
+ PlClient,
3
+ UnauthenticatedPlClient,
4
+ plAddressToConfig,
5
+ type AuthInformation,
6
+ type PlClientConfig,
7
+ } from "@milaboratories/pl-client";
8
+
9
+ export interface PlConnectionOptions {
10
+ address: string;
11
+ user?: string;
12
+ password?: string;
13
+ }
14
+
15
+ export interface AdminConnectionOptions {
16
+ address: string;
17
+ adminUser: string;
18
+ adminPassword: string;
19
+ }
20
+
21
+ /** Creates an authenticated PlClient from address + credentials. */
22
+ export async function createPlConnection(opts: PlConnectionOptions): Promise<PlClient> {
23
+ const config: PlClientConfig = plAddressToConfig(opts.address);
24
+
25
+ if (opts.user !== undefined) config.user = opts.user;
26
+ if (opts.password !== undefined) config.password = opts.password;
27
+
28
+ const unauth = await UnauthenticatedPlClient.build(config);
29
+ let authInformation: AuthInformation;
30
+
31
+ if (await unauth.requireAuth()) {
32
+ if (config.user === undefined || config.password === undefined) {
33
+ throw new Error(
34
+ "Server requires authentication but no credentials provided. " +
35
+ "Use --user/--password flags or PL_USER/PL_PASSWORD env vars.",
36
+ );
37
+ }
38
+ authInformation = await unauth.login(config.user, config.password);
39
+ } else {
40
+ authInformation = {};
41
+ }
42
+
43
+ return await PlClient.init(config, { authInformation });
44
+ }
45
+
46
+ /** Creates a PlClient with admin/controller credentials. */
47
+ export async function createAdminPlConnection(opts: AdminConnectionOptions): Promise<PlClient> {
48
+ const config: PlClientConfig = plAddressToConfig(opts.address);
49
+
50
+ const unauth = await UnauthenticatedPlClient.build(config);
51
+ const authInformation: AuthInformation = await unauth.login(opts.adminUser, opts.adminPassword);
52
+
53
+ return await PlClient.init(config, { authInformation });
54
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,21 @@
1
+ export {
2
+ createPlConnection,
3
+ createAdminPlConnection,
4
+ type PlConnectionOptions,
5
+ type AdminConnectionOptions,
6
+ } from "./connection";
7
+ export {
8
+ listProjects,
9
+ getProjectInfo,
10
+ getProjectListRid,
11
+ getExistingLabelsInTx,
12
+ resolveProject,
13
+ renameProject,
14
+ deleteProject,
15
+ deduplicateName,
16
+ navigateToUserRoot,
17
+ duplicateProject,
18
+ type ProjectEntry,
19
+ type ProjectInfo,
20
+ } from "./project_ops";
21
+ export { type OutputFormat, outputText, outputJson, formatTable, formatDate } from "./output";
package/src/output.ts ADDED
@@ -0,0 +1,35 @@
1
+ export type OutputFormat = "text" | "json";
2
+
3
+ /** Outputs a line of text to stdout. */
4
+ export function outputText(message: string): void {
5
+ console.log(message);
6
+ }
7
+
8
+ /** Outputs data as formatted JSON to stdout. */
9
+ export function outputJson(data: unknown): void {
10
+ console.log(JSON.stringify(data, null, 2));
11
+ }
12
+
13
+ /** Formats a table as aligned text columns. */
14
+ export function formatTable(headers: string[], rows: string[][]): string {
15
+ const colWidths = headers.map((h, i) =>
16
+ Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
17
+ );
18
+
19
+ const sep = colWidths.map((w) => "-".repeat(w)).join(" ");
20
+ const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(" ");
21
+ const dataLines = rows.map((row) =>
22
+ row.map((cell, i) => (cell ?? "").padEnd(colWidths[i])).join(" "),
23
+ );
24
+
25
+ return [headerLine, sep, ...dataLines].join("\n");
26
+ }
27
+
28
+ /** Formats a Date as a short human-readable string. */
29
+ export function formatDate(d: Date): string {
30
+ if (d.getTime() === 0) return "(unknown)";
31
+ return d
32
+ .toISOString()
33
+ .replace("T", " ")
34
+ .replace(/\.\d+Z$/, "Z");
35
+ }
@@ -0,0 +1,251 @@
1
+ import type { PlClient, ResourceId, PlTransaction } from "@milaboratories/pl-client";
2
+ import { field, isNullResourceId } from "@milaboratories/pl-client";
3
+ import {
4
+ ProjectMetaKey,
5
+ ProjectCreatedTimestamp,
6
+ ProjectLastModifiedTimestamp,
7
+ SchemaVersionKey,
8
+ ProjectStructureKey,
9
+ ProjectsField,
10
+ duplicateProject,
11
+ } from "@milaboratories/pl-middle-layer";
12
+ import type { ProjectMeta } from "@milaboratories/pl-middle-layer";
13
+ import { createHash } from "node:crypto";
14
+
15
+ export interface ProjectEntry {
16
+ id: string;
17
+ rid: string;
18
+ label: string;
19
+ created: Date;
20
+ lastModified: Date;
21
+ }
22
+
23
+ export interface ProjectInfo extends ProjectEntry {
24
+ schemaVersion: string | undefined;
25
+ blockCount: number;
26
+ blockIds: string[];
27
+ }
28
+
29
+ /** List all projects from a project list resource. */
30
+ export async function listProjects(
31
+ pl: PlClient,
32
+ projectListRid: ResourceId,
33
+ ): Promise<ProjectEntry[]> {
34
+ return await pl.withReadTx("listProjects", async (tx) => {
35
+ const data = await tx.getResourceData(projectListRid, true);
36
+ const entries: ProjectEntry[] = [];
37
+
38
+ for (const f of data.fields) {
39
+ if (isNullResourceId(f.value)) continue;
40
+
41
+ const [metaStr, createdStr, modifiedStr] = await Promise.all([
42
+ tx.getKValueStringIfExists(f.value, ProjectMetaKey),
43
+ tx.getKValueStringIfExists(f.value, ProjectCreatedTimestamp),
44
+ tx.getKValueStringIfExists(f.value, ProjectLastModifiedTimestamp),
45
+ ]);
46
+
47
+ const meta: ProjectMeta = metaStr ? JSON.parse(metaStr) : { label: "(unknown)" };
48
+
49
+ entries.push({
50
+ id: f.name,
51
+ rid: String(f.value),
52
+ label: meta.label,
53
+ created: createdStr ? new Date(Number(createdStr)) : new Date(0),
54
+ lastModified: modifiedStr ? new Date(Number(modifiedStr)) : new Date(0),
55
+ });
56
+ }
57
+
58
+ entries.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
59
+ return entries;
60
+ });
61
+ }
62
+
63
+ /** Get detailed info about a project. */
64
+ export async function getProjectInfo(
65
+ pl: PlClient,
66
+ projectListRid: ResourceId,
67
+ projectId: string,
68
+ ): Promise<ProjectInfo> {
69
+ return await pl.withReadTx("getProjectInfo", async (tx) => {
70
+ const fieldData = await tx.getField(field(projectListRid, projectId));
71
+ if (isNullResourceId(fieldData.value)) {
72
+ throw new Error(`Project "${projectId}" not found.`);
73
+ }
74
+
75
+ const rid = fieldData.value;
76
+ const kvs = await tx.listKeyValuesString(rid);
77
+
78
+ const metaKV = kvs.find((kv: { key: string }) => kv.key === ProjectMetaKey);
79
+ const createdKV = kvs.find((kv: { key: string }) => kv.key === ProjectCreatedTimestamp);
80
+ const modifiedKV = kvs.find((kv: { key: string }) => kv.key === ProjectLastModifiedTimestamp);
81
+ const schemaKV = kvs.find((kv: { key: string }) => kv.key === SchemaVersionKey);
82
+ const structureKV = kvs.find((kv: { key: string }) => kv.key === ProjectStructureKey);
83
+
84
+ const meta: ProjectMeta = metaKV ? JSON.parse(metaKV.value) : { label: "(unknown)" };
85
+ const schemaVersion = schemaKV ? JSON.parse(schemaKV.value) : undefined;
86
+
87
+ const blockIds: string[] = [];
88
+ if (structureKV) {
89
+ const structure = JSON.parse(structureKV.value);
90
+ for (const group of structure.groups ?? []) {
91
+ for (const block of group.blocks ?? []) {
92
+ blockIds.push(block.id);
93
+ }
94
+ }
95
+ }
96
+
97
+ return {
98
+ id: projectId,
99
+ rid: String(rid),
100
+ label: meta.label,
101
+ created: createdKV ? new Date(Number(createdKV.value)) : new Date(0),
102
+ lastModified: modifiedKV ? new Date(Number(modifiedKV.value)) : new Date(0),
103
+ schemaVersion,
104
+ blockCount: blockIds.length,
105
+ blockIds,
106
+ };
107
+ });
108
+ }
109
+
110
+ /** Resolve a project identifier (id or label) to its field ID and ResourceId. */
111
+ export async function resolveProject(
112
+ pl: PlClient,
113
+ projectListRid: ResourceId,
114
+ identifier: string,
115
+ ): Promise<{ id: string; rid: ResourceId }> {
116
+ return await pl.withReadTx("resolveProject", async (tx) => {
117
+ const data = await tx.getResourceData(projectListRid, true);
118
+ for (const f of data.fields) {
119
+ if (isNullResourceId(f.value)) continue;
120
+ if (f.name === identifier) {
121
+ return { id: f.name, rid: f.value };
122
+ }
123
+ const metaStr = await tx.getKValueStringIfExists(f.value, ProjectMetaKey);
124
+ if (metaStr) {
125
+ const meta: ProjectMeta = JSON.parse(metaStr);
126
+ if (meta.label === identifier) {
127
+ return { id: f.name, rid: f.value };
128
+ }
129
+ }
130
+ }
131
+
132
+ throw new Error(`Project "${identifier}" not found (searched by id and label).`);
133
+ });
134
+ }
135
+
136
+ /** Read all project labels within an existing transaction. */
137
+ export async function getExistingLabelsInTx(
138
+ tx: PlTransaction,
139
+ projectListRid: ResourceId,
140
+ ): Promise<string[]> {
141
+ const data = await tx.getResourceData(projectListRid, true);
142
+ const labels: string[] = [];
143
+ for (const f of data.fields) {
144
+ if (isNullResourceId(f.value)) continue;
145
+ const metaStr = await tx.getKValueStringIfExists(f.value, ProjectMetaKey);
146
+ if (metaStr) {
147
+ const meta: ProjectMeta = JSON.parse(metaStr);
148
+ labels.push(meta.label);
149
+ }
150
+ }
151
+ return labels;
152
+ }
153
+
154
+ /** Get all project labels from a project list. */
155
+ export async function getProjectLabels(
156
+ pl: PlClient,
157
+ projectListRid: ResourceId,
158
+ ): Promise<string[]> {
159
+ return await pl.withReadTx("getProjectLabels", async (tx) => {
160
+ return getExistingLabelsInTx(tx, projectListRid);
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Deduplicates a project name against existing labels.
166
+ * "X" → "X (Copy)" → "X (Copy 2)" → ...
167
+ */
168
+ export function deduplicateName(baseName: string, existingLabels: string[]): string {
169
+ let candidate = `${baseName} (Copy)`;
170
+ let i = 2;
171
+ while (existingLabels.includes(candidate)) {
172
+ candidate = `${baseName} (Copy ${i})`;
173
+ i++;
174
+ }
175
+ return candidate;
176
+ }
177
+
178
+ /** Rename a project (update its label). */
179
+ export async function renameProject(
180
+ pl: PlClient,
181
+ projectRid: ResourceId,
182
+ newLabel: string,
183
+ ): Promise<void> {
184
+ await pl.withWriteTx("renameProject", async (tx) => {
185
+ const metaStr = await tx.getKValueString(projectRid, ProjectMetaKey);
186
+ const meta: ProjectMeta = JSON.parse(metaStr);
187
+ const updated: ProjectMeta = { ...meta, label: newLabel };
188
+ tx.setKValue(projectRid, ProjectMetaKey, JSON.stringify(updated));
189
+ tx.setKValue(projectRid, ProjectLastModifiedTimestamp, String(Date.now()));
190
+ await tx.commit();
191
+ });
192
+ }
193
+
194
+ /** Delete a project from the project list. */
195
+ export async function deleteProject(
196
+ pl: PlClient,
197
+ projectListRid: ResourceId,
198
+ projectId: string,
199
+ ): Promise<void> {
200
+ await pl.withWriteTx("deleteProject", async (tx) => {
201
+ tx.removeField(field(projectListRid, projectId));
202
+ await tx.commit();
203
+ });
204
+ }
205
+
206
+ /** Get the project list ResourceId for the connected user. */
207
+ export async function getProjectListRid(pl: PlClient): Promise<ResourceId> {
208
+ return await pl.withReadTx("getProjectList", async (tx) => {
209
+ const fieldData = await tx.getField({
210
+ resourceId: tx.clientRoot,
211
+ fieldName: ProjectsField,
212
+ });
213
+ if (isNullResourceId(fieldData.value)) {
214
+ throw new Error("No project list found for this user.");
215
+ }
216
+ return fieldData.value;
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Navigates to a specific user's project list resource ID.
222
+ * Computes SHA256(username) to find the user's root, then reads the "projects" field.
223
+ */
224
+ export async function navigateToUserRoot(
225
+ pl: PlClient,
226
+ username: string,
227
+ ): Promise<{ userRoot: ResourceId; projectListRid: ResourceId }> {
228
+ const rootName = createHash("sha256").update(username).digest("hex");
229
+
230
+ return await pl.withReadTx("navigateToUserRoot", async (tx) => {
231
+ if (!(await tx.checkResourceNameExists(rootName))) {
232
+ throw new Error(`User "${username}" not found on this server (no root resource).`);
233
+ }
234
+
235
+ const userRootRid = await tx.getResourceByName(rootName);
236
+
237
+ const projectsFieldData = await tx.getField({
238
+ resourceId: userRootRid,
239
+ fieldName: ProjectsField,
240
+ });
241
+
242
+ if (isNullResourceId(projectsFieldData.value)) {
243
+ throw new Error(`User "${username}" has no project list.`);
244
+ }
245
+
246
+ return { userRoot: userRootRid, projectListRid: projectsFieldData.value };
247
+ });
248
+ }
249
+
250
+ // Re-export duplicateProject from pl-middle-layer for use in commands
251
+ export { duplicateProject };