@dreamlake/dreamlake-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/bin/dreamlake.js +41 -0
- package/dist/cli/auth/commands.js +240 -0
- package/dist/cli/auth/constants.js +16 -0
- package/dist/cli/auth/credentials.js +157 -0
- package/dist/cli/auth/device-flow.js +134 -0
- package/dist/cli/auth/device-secret.js +34 -0
- package/dist/cli/client.js +99 -0
- package/dist/cli/config.js +81 -0
- package/dist/cli/create/index.js +204 -0
- package/dist/cli/delete/index.js +228 -0
- package/dist/cli/download/index.js +128 -0
- package/dist/cli/glob.js +45 -0
- package/dist/cli/graphql-helpers.js +42 -0
- package/dist/cli/graphql.js +47 -0
- package/dist/cli/helpers.js +106 -0
- package/dist/cli/index.js +97 -0
- package/dist/cli/list/index.js +254 -0
- package/dist/cli/org/index.js +348 -0
- package/dist/cli/pipeline/index.js +481 -0
- package/dist/cli/progress.js +65 -0
- package/dist/cli/prompt.js +17 -0
- package/dist/cli/resources.js +134 -0
- package/dist/cli/target.js +85 -0
- package/dist/cli/team/index.js +411 -0
- package/dist/cli/update/index.js +256 -0
- package/dist/cli/upload/index.js +263 -0
- package/dist/cli/upload/kinds.js +85 -0
- package/dist/cli/upload/multipart.js +211 -0
- package/package.json +58 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// `dreamlake delete bindr|dataset <name> --project <target> [--yes]`.
|
|
2
|
+
// Port of dreamlake-py's cli/commands/delete.py.
|
|
3
|
+
import { HttpError, requestJson } from "../client.js";
|
|
4
|
+
import { resolveNamespace, resolveRemote, resolveToken } from "../config.js";
|
|
5
|
+
import { fail, ok } from "../helpers.js";
|
|
6
|
+
import { confirm } from "../prompt.js";
|
|
7
|
+
import { fetchAllEpisodes, fetchProjects, lookupNode, resolveProjectCtx, } from "../resources.js";
|
|
8
|
+
import { formatProject, parseTarget } from "../target.js";
|
|
9
|
+
async function runDelete(kind, name, opts) {
|
|
10
|
+
if (!opts.project) {
|
|
11
|
+
fail("--project is required");
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
const ctx = await resolveProjectCtx(opts.project);
|
|
15
|
+
if (!ctx)
|
|
16
|
+
return 1;
|
|
17
|
+
const { s, token, remote } = ctx;
|
|
18
|
+
const proj = formatProject(s);
|
|
19
|
+
if (!opts.yes) {
|
|
20
|
+
const proceed = await confirm(`Delete ${kind} '${name}' from ${proj}?`, false);
|
|
21
|
+
if (!proceed) {
|
|
22
|
+
process.stdout.write("Cancelled.\n");
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const collection = kind === "bindr" ? "bindrs" : "datasets";
|
|
27
|
+
try {
|
|
28
|
+
await requestJson(remote, `/namespaces/${s.namespace}/projects/${s.project}/${collection}/${name}`, { method: "DELETE", token });
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
32
|
+
fail(`${kind} '${name}' not found in ${proj}`);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
fail(err.message);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
ok(`Deleted ${kind}: ${name} from ${proj}`);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
export const runDeleteBindr = (name, opts) => runDelete("bindr", name, opts);
|
|
42
|
+
export const runDeleteDataset = (name, opts) => runDelete("dataset", name, opts);
|
|
43
|
+
// ─── node deletions (DELETE /nodes/:id — hard-deletes the subtree) ───
|
|
44
|
+
async function deleteNodeById(remote, token, nodeId) {
|
|
45
|
+
const res = await requestJson(remote, `/nodes/${nodeId}`, {
|
|
46
|
+
method: "DELETE",
|
|
47
|
+
token,
|
|
48
|
+
});
|
|
49
|
+
return res.deleted ?? 1;
|
|
50
|
+
}
|
|
51
|
+
export async function runDeleteProject(slug, opts) {
|
|
52
|
+
const remote = resolveRemote();
|
|
53
|
+
const token = resolveToken();
|
|
54
|
+
if (!token) {
|
|
55
|
+
fail("not authenticated. run 'dreamlake login' first");
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
const namespace = await resolveNamespace(opts.namespace, { token, remote });
|
|
59
|
+
if (!namespace) {
|
|
60
|
+
fail("namespace not specified and no authenticated user found. run 'dreamlake login'");
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
const projects = await fetchProjects(remote, token, namespace);
|
|
64
|
+
const proj = projects.find((p) => p.slug === slug);
|
|
65
|
+
if (!proj) {
|
|
66
|
+
fail(`project '${slug}' not found in namespace ${namespace}`);
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
if (!opts.yes) {
|
|
70
|
+
const proceed = await confirm(`HARD-DELETE project '${slug}' (${namespace}) and EVERYTHING inside it (episodes, files, S3 objects)?`, false);
|
|
71
|
+
if (!proceed) {
|
|
72
|
+
process.stdout.write("Cancelled.\n");
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const deleted = await deleteNodeById(remote, token, proj.id);
|
|
78
|
+
ok(`Deleted project: ${slug} (${deleted} node(s) removed)`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
fail(err.message);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function runDeleteEpisode(name, opts) {
|
|
87
|
+
if (!opts.project) {
|
|
88
|
+
fail("--project is required");
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const ctx = await resolveProjectCtx(opts.project);
|
|
92
|
+
if (!ctx)
|
|
93
|
+
return 1;
|
|
94
|
+
const { s, token, remote } = ctx;
|
|
95
|
+
const proj = formatProject(s);
|
|
96
|
+
const episodes = await fetchAllEpisodes(remote, s.namespace, s.project, token);
|
|
97
|
+
const ep = episodes.find((e) => e.name === name);
|
|
98
|
+
if (!ep) {
|
|
99
|
+
fail(`episode '${name}' not found in ${proj}`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
if (!opts.yes) {
|
|
103
|
+
const proceed = await confirm(`HARD-DELETE episode '${name}' from ${proj} and all files inside it?`, false);
|
|
104
|
+
if (!proceed) {
|
|
105
|
+
process.stdout.write("Cancelled.\n");
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const deleted = await deleteNodeById(remote, token, ep.id);
|
|
111
|
+
ok(`Deleted episode: ${name} from ${proj} (${deleted} node(s) removed)`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
fail(err.message);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function runDeleteFile(filePath, opts) {
|
|
120
|
+
if (!opts.episode) {
|
|
121
|
+
fail("--episode is required");
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
let t;
|
|
125
|
+
try {
|
|
126
|
+
t = parseTarget(opts.episode);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
fail(err.message);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
const remote = resolveRemote();
|
|
133
|
+
const token = resolveToken();
|
|
134
|
+
if (!token) {
|
|
135
|
+
fail("not authenticated. run 'dreamlake login' first");
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
const namespace = await resolveNamespace(t.namespace, { token, remote });
|
|
139
|
+
if (!namespace) {
|
|
140
|
+
fail("namespace not specified and no authenticated user found. run 'dreamlake login'");
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
const assetPath = "/" + filePath.replace(/^\/+/, "");
|
|
144
|
+
let node;
|
|
145
|
+
try {
|
|
146
|
+
node = await lookupNode(remote, token, {
|
|
147
|
+
namespace,
|
|
148
|
+
project: t.project,
|
|
149
|
+
path: assetPath,
|
|
150
|
+
episode: t.episode,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
155
|
+
fail(`nothing found at ${assetPath}`);
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
fail(err.message);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
if (!opts.yes) {
|
|
162
|
+
const what = node.kind === "folder" ? `folder '${assetPath}' and everything inside it` : `${node.kind} '${assetPath}'`;
|
|
163
|
+
const proceed = await confirm(`HARD-DELETE ${what}?`, false);
|
|
164
|
+
if (!proceed) {
|
|
165
|
+
process.stdout.write("Cancelled.\n");
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const deleted = await deleteNodeById(remote, token, node.id);
|
|
171
|
+
ok(`Deleted: ${assetPath} (${deleted} node(s) removed)`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
fail(err.message);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function registerDeleteCommand(program) {
|
|
180
|
+
const del = program
|
|
181
|
+
.command("delete")
|
|
182
|
+
.description("delete a project, episode, file, bindr, or dataset");
|
|
183
|
+
del
|
|
184
|
+
.command("project")
|
|
185
|
+
.description("HARD-delete a project and its entire subtree (episodes, files, S3)")
|
|
186
|
+
.argument("<slug>", "project slug")
|
|
187
|
+
.option("--namespace <slug>", "namespace (default: your own)")
|
|
188
|
+
.option("--yes", "skip the confirmation prompt")
|
|
189
|
+
.action(async (slug, opts) => {
|
|
190
|
+
process.exit(await runDeleteProject(slug, opts));
|
|
191
|
+
});
|
|
192
|
+
del
|
|
193
|
+
.command("episode")
|
|
194
|
+
.description("HARD-delete an episode and all files inside it")
|
|
195
|
+
.argument("<name>", "episode name")
|
|
196
|
+
.requiredOption("--project <target>", "project scope: space[@namespace]")
|
|
197
|
+
.option("--yes", "skip the confirmation prompt")
|
|
198
|
+
.action(async (name, opts) => {
|
|
199
|
+
process.exit(await runDeleteEpisode(name, opts));
|
|
200
|
+
});
|
|
201
|
+
del
|
|
202
|
+
.command("file")
|
|
203
|
+
.description("HARD-delete a file (or folder, recursively) inside an episode")
|
|
204
|
+
.argument("<path>", "path within the episode, e.g. /camera/front/clip.mp4")
|
|
205
|
+
.requiredOption("--episode <target>", "episode scope: space[@namespace][:episode]")
|
|
206
|
+
.option("--yes", "skip the confirmation prompt")
|
|
207
|
+
.action(async (p, opts) => {
|
|
208
|
+
process.exit(await runDeleteFile(p, opts));
|
|
209
|
+
});
|
|
210
|
+
del
|
|
211
|
+
.command("bindr")
|
|
212
|
+
.description("delete a bindr")
|
|
213
|
+
.argument("<name>", "bindr name")
|
|
214
|
+
.requiredOption("--project <target>", "project scope: space[@namespace]")
|
|
215
|
+
.option("--yes", "skip the confirmation prompt")
|
|
216
|
+
.action(async (name, opts) => {
|
|
217
|
+
process.exit(await runDeleteBindr(name, opts));
|
|
218
|
+
});
|
|
219
|
+
del
|
|
220
|
+
.command("dataset")
|
|
221
|
+
.description("delete a dataset")
|
|
222
|
+
.argument("<name>", "dataset name")
|
|
223
|
+
.requiredOption("--project <target>", "project scope: space[@namespace]")
|
|
224
|
+
.option("--yes", "skip the confirmation prompt")
|
|
225
|
+
.action(async (name, opts) => {
|
|
226
|
+
process.exit(await runDeleteDataset(name, opts));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// `dreamlake download --episode <target> --from <path> [-o <out>]`.
|
|
2
|
+
//
|
|
3
|
+
// Node-based download (the current server protocol):
|
|
4
|
+
// GET /nodes/lookup?namespace&project&path&episode → node record
|
|
5
|
+
// leaf → GET /nodes/:id/download → presigned URL → stream to disk
|
|
6
|
+
// container → GET /nodes/:id/contents → download every file, preserving
|
|
7
|
+
// the relative tree under the output directory
|
|
8
|
+
//
|
|
9
|
+
// (The old /assets/video/download route this command originally ported from
|
|
10
|
+
// dreamlake-py's CLI no longer exists server-side; any kind downloads now.)
|
|
11
|
+
import { createWriteStream, mkdirSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import { pipeline } from "node:stream/promises";
|
|
15
|
+
import { HttpError, requestJson, requestRaw } from "../client.js";
|
|
16
|
+
import { resolveNamespace, resolveRemote, resolveToken } from "../config.js";
|
|
17
|
+
import { fail, ok } from "../helpers.js";
|
|
18
|
+
import { BytesProgress } from "../progress.js";
|
|
19
|
+
import { fetchAllContents, lookupNode, relPathUnder, } from "../resources.js";
|
|
20
|
+
import { formatTarget, parseTarget } from "../target.js";
|
|
21
|
+
const CONTAINER_KINDS = new Set(["folder", "episode", "project"]);
|
|
22
|
+
async function downloadNodeToFile(remote, token, nodeId, dest) {
|
|
23
|
+
const presigned = await requestJson(remote, `/nodes/${nodeId}/download`, { token });
|
|
24
|
+
const res = await requestRaw(presigned.url, {});
|
|
25
|
+
const total = Number(res.headers.get("content-length") ?? 0);
|
|
26
|
+
const progress = new BytesProgress(total);
|
|
27
|
+
const body = res.body;
|
|
28
|
+
if (!body)
|
|
29
|
+
throw new Error("empty response body");
|
|
30
|
+
mkdirSync(path.dirname(path.resolve(dest)), { recursive: true });
|
|
31
|
+
const nodeStream = Readable.fromWeb(body);
|
|
32
|
+
nodeStream.on("data", (chunk) => progress.advance(chunk.length));
|
|
33
|
+
await pipeline(nodeStream, createWriteStream(dest));
|
|
34
|
+
progress.finish();
|
|
35
|
+
}
|
|
36
|
+
export async function runDownload(opts) {
|
|
37
|
+
if (!opts.episode) {
|
|
38
|
+
fail("--episode is required");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
if (!opts.from) {
|
|
42
|
+
fail("--from is required");
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
let t;
|
|
46
|
+
try {
|
|
47
|
+
t = parseTarget(opts.episode);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
fail(err.message);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
const remote = resolveRemote();
|
|
54
|
+
const token = resolveToken();
|
|
55
|
+
if (!token) {
|
|
56
|
+
fail("not authenticated. run 'dreamlake login' first");
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
const namespace = await resolveNamespace(t.namespace, { token, remote });
|
|
60
|
+
if (!namespace) {
|
|
61
|
+
fail("namespace not specified and no authenticated user found. run 'dreamlake login'");
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
t.namespace = namespace;
|
|
65
|
+
const assetPath = "/" + opts.from.replace(/^\/+/, "");
|
|
66
|
+
process.stdout.write(`Downloading ${assetPath}\n`);
|
|
67
|
+
process.stdout.write(` episode: ${formatTarget(t)}\n`);
|
|
68
|
+
// Resolve the node.
|
|
69
|
+
let node;
|
|
70
|
+
try {
|
|
71
|
+
node = await lookupNode(remote, token, {
|
|
72
|
+
namespace: t.namespace,
|
|
73
|
+
project: t.project,
|
|
74
|
+
path: assetPath,
|
|
75
|
+
episode: t.episode,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
80
|
+
fail(`asset not found at ${assetPath}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
fail(err.message);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// ── Single file ─────────────────────────────────────────────────
|
|
88
|
+
if (!CONTAINER_KINDS.has(node.kind)) {
|
|
89
|
+
const output = opts.output ?? path.basename(assetPath);
|
|
90
|
+
process.stdout.write(` output: ${output}\n`);
|
|
91
|
+
await downloadNodeToFile(remote, token, node.id, output);
|
|
92
|
+
ok(`Downloaded: ${output}`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
// ── Container (folder / episode / project) — recursive ──────────
|
|
96
|
+
const outDir = opts.output ?? node.name;
|
|
97
|
+
const prefix = `${node.path ?? ""}${node.name},`;
|
|
98
|
+
const files = await fetchAllContents(remote, token, node.id);
|
|
99
|
+
if (files.length === 0) {
|
|
100
|
+
process.stdout.write(` (no files under ${node.kind} '${node.name}')\n`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(` output: ${outDir}/ (${files.length} file(s) from ${node.kind} '${node.name}')\n`);
|
|
104
|
+
for (const f of files) {
|
|
105
|
+
const rel = relPathUnder(f, prefix);
|
|
106
|
+
const dest = path.join(outDir, rel);
|
|
107
|
+
process.stdout.write(` → ${rel}\n`);
|
|
108
|
+
await downloadNodeToFile(remote, token, f.id, dest);
|
|
109
|
+
}
|
|
110
|
+
ok(`Downloaded: ${outDir} (${files.length} files)`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
fail(err.message);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function registerDownloadCommand(program) {
|
|
119
|
+
program
|
|
120
|
+
.command("download")
|
|
121
|
+
.description("download a file (or a whole folder/episode, recursively) from DreamLake")
|
|
122
|
+
.requiredOption("--episode <target>", "episode scope: space[@namespace][:episode]")
|
|
123
|
+
.requiredOption("--from <path>", "source path within the episode (file or folder)")
|
|
124
|
+
.option("-o, --output <path>", "output file/directory (default: derived from --from)")
|
|
125
|
+
.action(async (opts) => {
|
|
126
|
+
process.exit(await runDownload(opts));
|
|
127
|
+
});
|
|
128
|
+
}
|
package/dist/cli/glob.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Minimal fnmatch-style glob matching — zero-dependency replacement for
|
|
2
|
+
// Python's `fnmatch.fnmatch`, which dreamlake-py uses to match episodes
|
|
3
|
+
// by node path and bindrs by name.
|
|
4
|
+
//
|
|
5
|
+
// Supports `*` (any run), `?` (single char), and `[seq]` / `[!seq]`
|
|
6
|
+
// character classes. Case-sensitive (POSIX fnmatch semantics).
|
|
7
|
+
function translate(pattern) {
|
|
8
|
+
let re = "";
|
|
9
|
+
let i = 0;
|
|
10
|
+
while (i < pattern.length) {
|
|
11
|
+
const c = pattern[i++];
|
|
12
|
+
if (c === "*") {
|
|
13
|
+
re += ".*";
|
|
14
|
+
}
|
|
15
|
+
else if (c === "?") {
|
|
16
|
+
re += ".";
|
|
17
|
+
}
|
|
18
|
+
else if (c === "[") {
|
|
19
|
+
let j = i;
|
|
20
|
+
if (pattern[j] === "!")
|
|
21
|
+
j++;
|
|
22
|
+
if (pattern[j] === "]")
|
|
23
|
+
j++;
|
|
24
|
+
while (j < pattern.length && pattern[j] !== "]")
|
|
25
|
+
j++;
|
|
26
|
+
if (j >= pattern.length) {
|
|
27
|
+
re += "\\[";
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
let stuff = pattern.slice(i, j).replace(/\\/g, "\\\\");
|
|
31
|
+
i = j + 1;
|
|
32
|
+
if (stuff.startsWith("!"))
|
|
33
|
+
stuff = "^" + stuff.slice(1);
|
|
34
|
+
re += `[${stuff}]`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
re += c.replace(/[.+^${}()|\\]/g, "\\$&");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return new RegExp(`^${re}$`);
|
|
42
|
+
}
|
|
43
|
+
export function matchGlob(name, pattern) {
|
|
44
|
+
return translate(pattern).test(name);
|
|
45
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Shared GraphQL resolution helpers for org/team commands: turn the
|
|
2
|
+
// slugs / emails users type into the ids the GraphQL API wants.
|
|
3
|
+
import { gql } from "./graphql.js";
|
|
4
|
+
/** Resolve an org slug to {id,name}, or null if not found. */
|
|
5
|
+
export async function resolveOrg(slug) {
|
|
6
|
+
const data = await gql(`query($slug:String!){ organizationBySlug(slug:$slug){ id name } }`, { slug });
|
|
7
|
+
return data.organizationBySlug;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a user reference (email or name) to a userId via the global
|
|
11
|
+
* searchUsers query. Exact email match wins; multiple matches → error
|
|
12
|
+
* listing them; zero → error.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveUserId(ref) {
|
|
15
|
+
const data = await gql(`query($kw:String!){ searchUsers(keyword:$kw, limit:10){ id name email } }`, { kw: ref });
|
|
16
|
+
const users = data.searchUsers ?? [];
|
|
17
|
+
if (users.length === 0)
|
|
18
|
+
throw new Error(`no user matches '${ref}'`);
|
|
19
|
+
const exact = users.filter((u) => u.email?.toLowerCase() === ref.toLowerCase());
|
|
20
|
+
if (exact.length === 1)
|
|
21
|
+
return exact[0].id;
|
|
22
|
+
if (users.length > 1) {
|
|
23
|
+
const list = users.map((u) => ` ${u.email ?? "?"} (${u.name ?? "?"})`).join("\n");
|
|
24
|
+
throw new Error(`'${ref}' is ambiguous — ${users.length} matches. Use an exact email:\n${list}`);
|
|
25
|
+
}
|
|
26
|
+
return users[0].id;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve (orgSlug, teamSlug) → team. Team slugs are unique only within an
|
|
30
|
+
* org, and the API has no teamBySlug query, so we list the org's teams and
|
|
31
|
+
* match by slug. Returns null if either the org or the team is not found.
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveTeam(orgSlug, teamSlug) {
|
|
34
|
+
const org = await resolveOrg(orgSlug);
|
|
35
|
+
if (!org)
|
|
36
|
+
return null;
|
|
37
|
+
const data = await gql(`query($orgId:ID!){ organizationTeams(orgId:$orgId){ id slug name } }`, { orgId: org.id });
|
|
38
|
+
const team = (data.organizationTeams ?? []).find((t) => t.slug === teamSlug);
|
|
39
|
+
if (!team)
|
|
40
|
+
return null;
|
|
41
|
+
return { id: team.id, slug: team.slug, name: team.name, orgId: org.id };
|
|
42
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Minimal GraphQL client. Organization/team operations live on the
|
|
2
|
+
// dreamlake-server GraphQL endpoint (POST {server}/graphql) rather than
|
|
3
|
+
// the REST routes the rest of the CLI uses — same bearer token, same host.
|
|
4
|
+
import { resolveRemote, resolveToken } from "./config.js";
|
|
5
|
+
export class GraphQLClientError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Run a GraphQL query/mutation against {server}/graphql with the saved
|
|
9
|
+
* token. Throws GraphQLClientError on transport errors or a non-empty
|
|
10
|
+
* `errors` array (messages joined). Returns the `data` payload.
|
|
11
|
+
*/
|
|
12
|
+
export async function gql(query, variables = {}, opts = {}) {
|
|
13
|
+
const token = resolveToken();
|
|
14
|
+
if (!token) {
|
|
15
|
+
throw new GraphQLClientError("not authenticated. run 'dreamlake login' first");
|
|
16
|
+
}
|
|
17
|
+
const remote = resolveRemote();
|
|
18
|
+
let res;
|
|
19
|
+
try {
|
|
20
|
+
res = await fetch(`${remote}/graphql`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
Authorization: `Bearer ${token}`,
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({ query, variables }),
|
|
27
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 30000),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw new GraphQLClientError(`request failed: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
let body;
|
|
34
|
+
try {
|
|
35
|
+
body = (await res.json());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new GraphQLClientError(`(${res.status}) non-JSON response`);
|
|
39
|
+
}
|
|
40
|
+
if (body.errors && body.errors.length > 0) {
|
|
41
|
+
throw new GraphQLClientError(body.errors.map((e) => e.message).join("; "));
|
|
42
|
+
}
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new GraphQLClientError(`(${res.status}) request failed`);
|
|
45
|
+
}
|
|
46
|
+
return body.data;
|
|
47
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Small shared helpers for the dreamlake CLI subcommands. Adapted from
|
|
2
|
+
// lakeshore/src/cli/helpers.ts — zero dependencies beyond `yaml` (already
|
|
3
|
+
// a dep for the auth file). Keep this file presentation-only: glyphs,
|
|
4
|
+
// table rendering, JSON emit, CSV/tag parsing.
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import YAML from "yaml";
|
|
8
|
+
// ─── ✓ / ✗ / ⚠ glyph wrappers ────────────────────────────────────────
|
|
9
|
+
export function ok(line) {
|
|
10
|
+
process.stdout.write(`✓ ${line}\n`);
|
|
11
|
+
}
|
|
12
|
+
export function warn(line) {
|
|
13
|
+
process.stdout.write(`⚠ ${line}\n`);
|
|
14
|
+
}
|
|
15
|
+
export function fail(line) {
|
|
16
|
+
process.stderr.write(`✗ ${line}\n`);
|
|
17
|
+
}
|
|
18
|
+
// ─── splitCsv — comma-separated values → trimmed non-empty list ──────
|
|
19
|
+
//
|
|
20
|
+
// Used for `--tags a,b,c` and `--bindr name1,name2`. Mirrors the Python
|
|
21
|
+
// CLI's `[t.strip() for t in s.split(",") if t.strip()]`.
|
|
22
|
+
export function splitCsv(raw) {
|
|
23
|
+
if (!raw)
|
|
24
|
+
return [];
|
|
25
|
+
return raw
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((s) => s.trim())
|
|
28
|
+
.filter((s) => s.length > 0);
|
|
29
|
+
}
|
|
30
|
+
// ─── renderTable ─────────────────────────────────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// Minimal monospaced table renderer. Columns are exactly the `cols`
|
|
33
|
+
// names; each row may have arbitrary keys (others are ignored).
|
|
34
|
+
// Returns the rendered string (caller prints).
|
|
35
|
+
export function renderTable(rows, cols) {
|
|
36
|
+
if (rows.length === 0) {
|
|
37
|
+
return "(none)\n";
|
|
38
|
+
}
|
|
39
|
+
const stringRows = rows.map((r) => cols.map((c) => stringifyCell(r[c])));
|
|
40
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...stringRows.map((r) => r[i].length)));
|
|
41
|
+
const lines = [];
|
|
42
|
+
// Header.
|
|
43
|
+
lines.push(cols
|
|
44
|
+
.map((c, i) => c.padEnd(widths[i]))
|
|
45
|
+
.join(" ")
|
|
46
|
+
.trimEnd());
|
|
47
|
+
// Body.
|
|
48
|
+
for (const r of stringRows) {
|
|
49
|
+
lines.push(r.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd());
|
|
50
|
+
}
|
|
51
|
+
return lines.join("\n") + "\n";
|
|
52
|
+
}
|
|
53
|
+
function stringifyCell(v) {
|
|
54
|
+
if (v == null)
|
|
55
|
+
return "—";
|
|
56
|
+
if (typeof v === "string")
|
|
57
|
+
return v;
|
|
58
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
59
|
+
return String(v);
|
|
60
|
+
if (Array.isArray(v))
|
|
61
|
+
return v.map(stringifyCell).join(", ");
|
|
62
|
+
return JSON.stringify(v);
|
|
63
|
+
}
|
|
64
|
+
// ─── emitJson — for `--json` / `--json-output` flags ─────────────────
|
|
65
|
+
//
|
|
66
|
+
// Single canonical JSON emitter. 2-space indent, trailing newline.
|
|
67
|
+
export function emitJson(value) {
|
|
68
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
69
|
+
}
|
|
70
|
+
// ─── loadStructuredFile — YAML or JSON ───────────────────────────────
|
|
71
|
+
export function loadStructuredFile(filePath) {
|
|
72
|
+
const resolved = path.resolve(filePath);
|
|
73
|
+
if (!existsSync(resolved)) {
|
|
74
|
+
throw new Error(`file not found: ${resolved}`);
|
|
75
|
+
}
|
|
76
|
+
const text = readFileSync(resolved, "utf8");
|
|
77
|
+
const trimmed = text.trim();
|
|
78
|
+
if (trimmed.startsWith("{")) {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(trimmed);
|
|
81
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
throw new Error("expected an object at top level");
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
throw new Error(`failed to parse JSON ${resolved}: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const parsed = YAML.parse(text);
|
|
92
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
throw new Error("expected an object at top level");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
throw new Error(`failed to parse YAML ${resolved}: ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// ─── humanSize — bytes → "1.5 MB" / "12.3 KB" ───────────────────────
|
|
102
|
+
export function humanSize(bytes) {
|
|
103
|
+
if (bytes > 1024 * 1024)
|
|
104
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
105
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
106
|
+
}
|