@cobinar/dalus 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/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # dalus
2
+
3
+ A command-line deploy tool for Cobinar — the wrangler-equivalent for
4
+ Edge Compute, Static Hosting, Object Storage, Document DB, and Vault.
5
+ Doesn't talk to Cloudflare or implement any deploy mechanism of its own:
6
+ every command here is a thin, scriptable front door to
7
+ `cobinar-dashboard-worker`'s own REST API — the exact same endpoints the
8
+ web dashboard calls. See that project's README for the full endpoint
9
+ reference this is built on.
10
+
11
+ **Status**: v1. Real and working end-to-end against a mock of the real
12
+ API (27 passing scenarios — login, every resource kind, dependency
13
+ ordering, idempotent re-runs, named environments, both JSONC and TOML
14
+ config, and clean failure when not logged in) — not yet run against an
15
+ actual deployed `cobinar-dashboard-worker`, since this was built without
16
+ network access to one. The one thing worth a real smoke test before
17
+ relying on this day to day.
18
+
19
+ ## Install
20
+
21
+ ```
22
+ cd dalus
23
+ npm install
24
+ npm link # makes the `dalus` command available globally
25
+ ```
26
+
27
+ (Not published to npm — this is source you own and can change.)
28
+
29
+ ## Log in
30
+
31
+ ```
32
+ dalus login --api-base https://worker.lobby.cobinar.com --token <your bearer token>
33
+ ```
34
+
35
+ dalus doesn't implement Cobinar's own signup/login flow — that lives in
36
+ `cobinar-developers-worker`, a separate system. This command stores a
37
+ token you've already obtained from wherever that flow gives you one, the
38
+ same way `gh auth login --with-token` accepts a token instead of
39
+ reimplementing GitHub's own login. Omit `--api-base`/`--token` to be
40
+ prompted instead. Credentials are stored in `~/.dalus/credentials.json`
41
+ (mode 0600), never inside a project directory.
42
+
43
+ `dalus login --whoami` shows what's currently stored (token
44
+ redacted). `dalus login --logout` clears it.
45
+
46
+ ## Set up a project
47
+
48
+ ```
49
+ dalus init
50
+ ```
51
+
52
+ writes a starter `dalus.jsonc` with every resource kind commented out —
53
+ uncomment and fill in what your project actually needs. See
54
+ `dalus.jsonc`'s own comments for the full shape, or the example below.
55
+
56
+ ## Deploy
57
+
58
+ ```
59
+ dalus forge # deploys everything in the config
60
+ dalus forge --pages # just the pages[] entries
61
+ dalus forge --workers # just the workers[] entries
62
+ dalus forge --env staging # deploys the [env.staging] block instead
63
+ dalus forge --name my-api # just the one resource with this name
64
+ ```
65
+
66
+ `dalus forge` looks for `dalus.jsonc`, `dalus.toml`, or `dalus.json` in
67
+ the current directory, in that order — first one found wins. A bare
68
+ `dalus forge` deploys `storage`, `database`, and `vault` first, then
69
+ `pages`, then `workers` last — not the order they're written in the
70
+ config — since a worker's bindings reference a bucket or collection by
71
+ name and need that resource to already exist. Passing specific
72
+ `--pages`/`--workers`/etc. flags deploys just those kinds, in the order
73
+ given; if you're deploying a worker with bindings in isolation (without
74
+ its target resources already forged), you'll get a clear error naming
75
+ exactly what's missing, not a confusing API failure.
76
+
77
+ Every resource is **find-or-create by name**: running `forge` again
78
+ against a project that's already deployed updates it in place rather
79
+ than creating a duplicate. Bindings and secrets sync the same way —
80
+ already-present bindings are left alone, secrets are always re-set
81
+ (harmless if the value hasn't changed).
82
+
83
+ ### Example `dalus.jsonc`
84
+
85
+ ```jsonc
86
+ {
87
+ "workers": [
88
+ { "name": "guestbook-api", "main": "./worker-service.js",
89
+ "bindings": [
90
+ { "name": "AVATARS", "type": "storage", "resource": "guestbook-avatars" },
91
+ { "name": "CONFIG", "type": "database", "resource": "guestbook_config" }
92
+ ],
93
+ "secrets": ["GROQ_API_KEY"] }
94
+ ],
95
+ "pages": [
96
+ { "name": "guestbook-site", "dir": "./pages" }
97
+ ],
98
+ "storage": [
99
+ { "name": "guestbook-avatars", "dir": "./avatars", "public": true }
100
+ ],
101
+ "database": [
102
+ { "name": "guestbook_config", "seed": "./config-seed.json" }
103
+ ],
104
+ "vault": [
105
+ { "name": "guestbook-entries", "rules": "./vault-rules.txt" }
106
+ ]
107
+ }
108
+ ```
109
+
110
+ (This is, deliberately, the exact shape of the Cobinar Guestbook demo
111
+ from an earlier session — try pointing a `dalus.jsonc` at it.)
112
+
113
+ ### Secrets
114
+
115
+ List secret **names** in config, never values — `dalus forge` resolves
116
+ each one from an environment variable of the same name
117
+ (`GROQ_API_KEY=sk-... dalus forge`), or prompts for it interactively if
118
+ the variable isn't set. `--yes` skips the prompt and leaves unset
119
+ secrets alone (for CI, where an interactive prompt would just hang).
120
+
121
+ ### Named environments
122
+
123
+ ```jsonc
124
+ {
125
+ "workers": [ /* ... */ ],
126
+ "env": {
127
+ "staging": {
128
+ "workers": [ { "name": "my-api-staging", "main": "./worker.js" } ]
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ `--env staging` uses `env.staging`'s lists **instead of** the top-level
135
+ ones, per resource kind — not a deep merge. A resource kind the env
136
+ block doesn't mention falls back to the top-level list for that kind
137
+ only. Simpler than Wrangler's own merge behavior, on purpose: easy to
138
+ predict exactly what a given `--env` run will touch just by reading that
139
+ one block.
140
+
141
+ ## What's explicitly not here yet
142
+
143
+ - **The custom config-language idea** from the original ask (a language
144
+ built specifically for this tool) — deferred by request; `dalus.jsonc`
145
+ today is just JSONC, `dalus.toml` is just TOML.
146
+ - **SSL/TLS strictness flags** (`dalus --ssl/tls --1/2/3` or similar) —
147
+ also explicitly deferred; nothing here touches TLS configuration.
148
+ - **Diffing/cleanup** — Pages and Storage uploads re-push every file in
149
+ the configured directory every run; a file removed locally isn't
150
+ removed remotely. Database seeding only ever adds documents (no
151
+ matching-and-updating). A `--clean` or `--sync` mode that reconciles
152
+ instead of just re-uploading would be the natural next step if that
153
+ gap turns out to matter in practice.
154
+ - **`dalus forge --workers --env`** from the original examples — `--env`
155
+ works as a flag needing a value (`--env staging`); if what was meant
156
+ was `--env` as a bare toggle with different behavior, that's worth
157
+ clarifying, since this implementation treats it the same way whether
158
+ or not `--workers` is also passed.
package/bin/dalus.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { loginCommand } = require('../src/commands/login');
5
+ const { forgeCommand } = require('../src/commands/forge');
6
+ const { initCommand } = require('../src/commands/init');
7
+
8
+ const HELP = `dalus — deploy Cobinar projects from the command line
9
+
10
+ Usage:
11
+ dalus login [--api-base <url>] [--token <token>]
12
+ dalus login --whoami
13
+ dalus login --logout
14
+ dalus init [--force]
15
+ dalus forge [--pages] [--workers] [--storage] [--database] [--vault]
16
+ [--env <name>] [--name <resourceName>] [--yes]
17
+
18
+ Bare "dalus forge" deploys every resource in dalus.jsonc / dalus.toml /
19
+ dalus.json found in the current directory. Passing one or more of
20
+ --pages/--workers/--storage/--database/--vault narrows it to just
21
+ those kinds. --env <name> deploys the [env.<name>] block instead of
22
+ the top-level config. --name <resourceName> deploys just the one
23
+ resource with that name. --yes skips interactive secret prompts
24
+ (secrets with no matching environment variable are left unset).
25
+
26
+ Examples:
27
+ dalus forge --pages
28
+ dalus forge --workers --env staging
29
+ dalus forge
30
+ `;
31
+
32
+ async function main() {
33
+ const [, , command, ...args] = process.argv;
34
+
35
+ switch (command) {
36
+ case 'login':
37
+ await loginCommand(args);
38
+ break;
39
+ case 'forge':
40
+ await forgeCommand(args);
41
+ break;
42
+ case 'init':
43
+ initCommand(args);
44
+ break;
45
+ case '--help':
46
+ case '-h':
47
+ case undefined:
48
+ console.log(HELP);
49
+ break;
50
+ default:
51
+ console.error(`Unknown command: ${command}\n`);
52
+ console.log(HELP);
53
+ process.exitCode = 1;
54
+ }
55
+ }
56
+
57
+ main().catch((err) => {
58
+ console.error(err.message || err);
59
+ process.exitCode = 1;
60
+ });
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@cobinar/dalus",
3
+ "version": "0.1.0",
4
+ "description": "Deploy Cobinar projects (Edge Compute, Static Hosting, Object Storage, Document DB, Vault) from the command line.",
5
+ "bin": {
6
+ "dalus": "./bin/dalus.js"
7
+ },
8
+ "main": "./src/index.js",
9
+ "type": "commonjs",
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "dependencies": {
14
+ "jsonc-parser": "^3.3.1",
15
+ "smol-toml": "^1.3.1"
16
+ },
17
+ "license": "MIT"
18
+ }
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+ // A thin client over cobinar-dashboard-worker's real REST API — every
3
+ // call here hits an endpoint that already exists and is already used by
4
+ // the dashboard itself (see that project's README for the full list).
5
+ // dalus doesn't talk to Cloudflare directly and has no separate deploy
6
+ // mechanism of its own; it's a scriptable front door to the same API the
7
+ // web dashboard calls, nothing more.
8
+
9
+ class DalusApiError extends Error {
10
+ constructor(message, status, body) {
11
+ super(message);
12
+ this.status = status;
13
+ this.body = body;
14
+ }
15
+ }
16
+
17
+ class ApiClient {
18
+ constructor({ apiBase, token }) {
19
+ if (!apiBase) throw new Error('apiBase is required (set it in your dalus config, or run "dalus login").');
20
+ if (!token) throw new Error('Not logged in — run "dalus login" first.');
21
+ this.apiBase = apiBase.replace(/\/$/, '');
22
+ this.token = token;
23
+ }
24
+
25
+ async request(method, path, { body, headers, rawBody } = {}) {
26
+ const res = await fetch(`${this.apiBase}${path}`, {
27
+ method,
28
+ headers: {
29
+ authorization: `Bearer ${this.token}`,
30
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
31
+ ...headers,
32
+ },
33
+ body: rawBody !== undefined ? rawBody : body !== undefined ? JSON.stringify(body) : undefined,
34
+ });
35
+ const text = await res.text();
36
+ let parsed;
37
+ try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
38
+ if (!res.ok) {
39
+ const message = (parsed && typeof parsed === 'object' && parsed.error) || `${method} ${path} failed with ${res.status}`;
40
+ throw new DalusApiError(message, res.status, parsed);
41
+ }
42
+ return parsed;
43
+ }
44
+
45
+ get(path) { return this.request('GET', path); }
46
+ post(path, body) { return this.request('POST', path, { body }); }
47
+ patch(path, body) { return this.request('PATCH', path, { body }); }
48
+ put(path, body) { return this.request('PUT', path, { body }); }
49
+ delete(path) { return this.request('DELETE', path); }
50
+ putRaw(path, rawBody, contentType) {
51
+ return this.request('PUT', path, { rawBody, headers: contentType ? { 'content-type': contentType } : undefined });
52
+ }
53
+
54
+ // ---- find-or-create helpers ----
55
+ // Every resource kind exposes a GET list + POST create, and every
56
+ // create schema 400s on a duplicate name scoped the way that resource
57
+ // scopes names (see each route file) — so "does this already exist"
58
+ // is answered by listing and matching on name, not a dedicated
59
+ // lookup-by-name endpoint (none of these APIs have one).
60
+
61
+ async findByName(listPath, name) {
62
+ const rows = await this.get(listPath);
63
+ return rows.find((r) => r.name === name) || null;
64
+ }
65
+ }
66
+
67
+ module.exports = { ApiClient, DalusApiError };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+ const { loadConfig, resolveEnv, loadCredentials } = require('../config');
3
+ const { ApiClient } = require('../api-client');
4
+ const { forgeWorker } = require('../forgers/workers');
5
+ const { forgePages } = require('../forgers/pages');
6
+ const { forgeStorage } = require('../forgers/storage');
7
+ const { forgeDatabase } = require('../forgers/database');
8
+ const { forgeVault } = require('../forgers/vault');
9
+
10
+ const KIND_FLAGS = {
11
+ workers: '--workers',
12
+ pages: '--pages',
13
+ storage: '--storage',
14
+ database: '--database',
15
+ vault: '--vault',
16
+ };
17
+ const FORGERS = {
18
+ workers: (api, log, entry, opts) => forgeWorker(api, log, entry, opts),
19
+ pages: (api, log, entry) => forgePages(api, log, entry),
20
+ storage: (api, log, entry) => forgeStorage(api, log, entry),
21
+ database: (api, log, entry) => forgeDatabase(api, log, entry),
22
+ vault: (api, log, entry) => forgeVault(api, log, entry),
23
+ };
24
+ // Bare `dalus forge` (no kind flags) deploys every kind — in THIS order,
25
+ // not object-key order: storage/database/vault/pages have no
26
+ // dependencies on anything else in this list, while a worker's bindings
27
+ // reference a storage bucket or Document DB collection by name and fail
28
+ // with a clear "doesn't exist yet" error if that resource isn't already
29
+ // forged. Deploying workers last means a bare `dalus forge` on a brand
30
+ // new project just works, instead of depending on running it twice.
31
+ const DEFAULT_KIND_ORDER = ['storage', 'database', 'vault', 'pages', 'workers'];
32
+
33
+ function parseForgeArgs(args) {
34
+ const opts = { kinds: [], env: null, yes: args.includes('--yes'), only: null };
35
+ for (const [kind, flag] of Object.entries(KIND_FLAGS)) {
36
+ if (args.includes(flag)) opts.kinds.push(kind);
37
+ }
38
+ const envIdx = args.indexOf('--env');
39
+ if (envIdx !== -1) opts.env = args[envIdx + 1];
40
+ const onlyIdx = args.indexOf('--name');
41
+ if (onlyIdx !== -1) opts.only = args[onlyIdx + 1]; // deploy just the one resource with this name, across whichever kind(s) it's found under
42
+ return opts;
43
+ }
44
+
45
+ // `dalus forge` with no --pages/--workers/etc. deploys every resource
46
+ // kind present in the config — bare `dalus forge` is meant to mean "ship
47
+ // this whole project," matching the exact example in the original ask.
48
+ // Naming one or more kind flags narrows it to just those.
49
+ async function forgeCommand(args) {
50
+ const opts = parseForgeArgs(args);
51
+ const creds = loadCredentials();
52
+ if (!creds) {
53
+ console.error('Not logged in. Run "dalus login" first.');
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+
58
+ let config;
59
+ try {
60
+ config = loadConfig();
61
+ if (opts.env) config = resolveEnv(config, opts.env);
62
+ } catch (err) {
63
+ console.error(err.message);
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+
68
+ const api = new ApiClient({ apiBase: config.apiBase || creds.apiBase, token: creds.token });
69
+ const kindsToRun = opts.kinds.length > 0 ? opts.kinds : DEFAULT_KIND_ORDER;
70
+
71
+ const results = [];
72
+ for (const kind of kindsToRun) {
73
+ const entries = (config[kind] || []).filter((e) => !opts.only || e.name === opts.only);
74
+ for (const entry of entries) {
75
+ try {
76
+ await FORGERS[kind](api, (line) => console.log(line), entry, opts);
77
+ results.push({ kind, name: entry.name, ok: true });
78
+ } catch (err) {
79
+ console.error(` FAILED: ${err.message}`);
80
+ results.push({ kind, name: entry.name, ok: false, error: err.message });
81
+ }
82
+ console.log('');
83
+ }
84
+ }
85
+
86
+ if (results.length === 0) {
87
+ console.log(
88
+ opts.kinds.length > 0
89
+ ? `No ${opts.kinds.join('/')} entries found in ${config.__configPath}.`
90
+ : `Nothing to deploy — ${config.__configPath} has no workers/pages/storage/database/vault entries.`,
91
+ );
92
+ return;
93
+ }
94
+
95
+ const failed = results.filter((r) => !r.ok);
96
+ console.log(`${results.length - failed.length}/${results.length} deployed successfully.`);
97
+ if (failed.length > 0) {
98
+ console.log('Failed:');
99
+ for (const f of failed) console.log(` - ${f.kind} ${f.name}: ${f.error}`);
100
+ process.exitCode = 1;
101
+ }
102
+ }
103
+
104
+ module.exports = { forgeCommand };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const TEMPLATE = `{
6
+ // dalus config — see https://github.com/ (your Cobinar docs link here)
7
+ // for the full field reference. Paths below are relative to this file.
8
+
9
+ // Optional — if omitted, dalus uses whatever worker URL you logged in
10
+ // with ("dalus login"). Set this when a project should always deploy
11
+ // to a specific worker regardless of who's running "dalus forge".
12
+ // "apiBase": "https://worker.lobby.cobinar.com",
13
+
14
+ "workers": [
15
+ // { "name": "my-api", "main": "./worker.js",
16
+ // "bindings": [
17
+ // { "name": "MY_BUCKET", "type": "storage", "resource": "my-bucket" }
18
+ // ],
19
+ // "secrets": ["SOME_API_KEY"] }
20
+ ],
21
+ "pages": [
22
+ // { "name": "my-site", "dir": "./public" }
23
+ ],
24
+ "storage": [
25
+ // { "name": "my-bucket", "dir": "./assets", "public": true }
26
+ ],
27
+ "database": [
28
+ // { "name": "my_collection", "seed": "./seed.json" }
29
+ ],
30
+ "vault": [
31
+ // { "name": "my-vault", "rules": "./rules.txt" }
32
+ ]
33
+
34
+ // Named environments (optional) — "dalus forge --env staging" uses
35
+ // these instead of the top-level lists above, per resource kind:
36
+ // "env": {
37
+ // "staging": { "workers": [ { "name": "my-api-staging", "main": "./worker.js" } ] }
38
+ // }
39
+ }
40
+ `;
41
+
42
+ function initCommand(args) {
43
+ const target = path.join(process.cwd(), 'dalus.jsonc');
44
+ if (fs.existsSync(target) && !args.includes('--force')) {
45
+ console.error(`${target} already exists — pass --force to overwrite.`);
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ fs.writeFileSync(target, TEMPLATE);
50
+ console.log(`Created ${target}. Fill in the resources you want, then run "dalus forge".`);
51
+ }
52
+
53
+ module.exports = { initCommand };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ const readline = require('readline');
3
+ const { saveCredentials, clearCredentials, loadCredentials, CREDENTIALS_PATH } = require('../config');
4
+
5
+ function ask(question) {
6
+ return new Promise((resolve) => {
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
9
+ });
10
+ }
11
+
12
+ // dalus does not implement Cobinar's own signup/login flow itself — that
13
+ // lives in cobinar-developers-worker (a separate system) and issues the
14
+ // bearer token the dashboard's browser session already uses. This
15
+ // command just stores a token you've already obtained (from that flow,
16
+ // wherever it prompts you for one) so dalus can send it on every
17
+ // request, the same way `gh auth login --with-token` accepts a token
18
+ // you got some other way rather than reimplementing GitHub's own login.
19
+ async function loginCommand(args) {
20
+ if (args.includes('--logout')) {
21
+ clearCredentials();
22
+ console.log('Logged out.');
23
+ return;
24
+ }
25
+ if (args.includes('--whoami')) {
26
+ const creds = loadCredentials();
27
+ if (!creds) { console.log('Not logged in.'); return; }
28
+ console.log(`API base: ${creds.apiBase}`);
29
+ console.log(`Token: ${creds.token.slice(0, 8)}...${creds.token.slice(-4)} (stored in ${CREDENTIALS_PATH})`);
30
+ return;
31
+ }
32
+
33
+ const apiBaseArgIdx = args.indexOf('--api-base');
34
+ const tokenArgIdx = args.indexOf('--token');
35
+ const apiBase = apiBaseArgIdx !== -1 ? args[apiBaseArgIdx + 1] : await ask('Cobinar dashboard-worker URL (e.g. https://worker.lobby.cobinar.com): ');
36
+ // Visible input, deliberately — Node's readline has no built-in masked
37
+ // prompt, and a hand-rolled no-echo hack is the kind of thing that's
38
+ // easy to get subtly wrong (garbled input, doesn't work across
39
+ // terminals) without a real TTY to test it against. --token on the
40
+ // command line, or piping the value in, avoids the prompt entirely if
41
+ // that matters more than a visible paste in a scrollback buffer.
42
+ const token = tokenArgIdx !== -1 ? args[tokenArgIdx + 1] : await ask('Bearer token (visible as you type/paste — see the note in README.md): ');
43
+
44
+ if (!apiBase || !token) {
45
+ console.error('Both a worker URL and a token are required.');
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+
50
+ saveCredentials({ apiBase: apiBase.replace(/\/$/, ''), token });
51
+ console.log(`Saved to ${CREDENTIALS_PATH}. Run "dalus forge" from a project directory to deploy.`);
52
+ }
53
+
54
+ module.exports = { loginCommand };
package/src/config.js ADDED
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+ // Config discovery and credential storage for dalus.
3
+ //
4
+ // Project config: dalus.jsonc, dalus.toml, or dalus.json, checked in the
5
+ // current directory in that order (first one found wins — this is the
6
+ // exact order named in the original ask: "look for file named
7
+ // dalus.jsonc, dalus.toml, or dalus."). Login credentials are kept
8
+ // entirely separate, in ~/.dalus/credentials.json, same reasoning
9
+ // Wrangler keeps its own auth out of the project directory: a project
10
+ // config is something you'd commit; a bearer token is not.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const toml = require('smol-toml');
16
+ const jsonc = require('jsonc-parser');
17
+
18
+ const CONFIG_FILENAMES = ['dalus.jsonc', 'dalus.toml', 'dalus.json'];
19
+ const CREDENTIALS_DIR = path.join(os.homedir(), '.dalus');
20
+ const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json');
21
+
22
+ class DalusConfigError extends Error {}
23
+
24
+ /** Searches the given directory (default: cwd) for a dalus config file,
25
+ * in the fixed priority order above. Returns { path, format } or null if
26
+ * none exist — forge.js decides what "none found" means for the command
27
+ * being run, this module just reports the fact. */
28
+ function findConfigFile(dir = process.cwd()) {
29
+ for (const name of CONFIG_FILENAMES) {
30
+ const p = path.join(dir, name);
31
+ if (fs.existsSync(p)) {
32
+ return { path: p, format: path.extname(name).slice(1) };
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** Loads and parses whichever config file is found, resolving `dir`/
39
+ * `main`/`rules`/`seed` fields in every resource entry to absolute paths
40
+ * relative to the config file's own directory — so a project's resource
41
+ * definitions keep working regardless of what directory `dalus forge`
42
+ * happens to be invoked from. */
43
+ function loadConfig(dir = process.cwd()) {
44
+ const found = findConfigFile(dir);
45
+ if (!found) {
46
+ throw new DalusConfigError(
47
+ `No dalus.jsonc, dalus.toml, or dalus.json found in ${dir}.\nRun "dalus init" to create one, or pass --pages/--workers/etc. with the other required flags directly.`,
48
+ );
49
+ }
50
+ const raw = fs.readFileSync(found.path, 'utf8');
51
+ let parsed;
52
+ try {
53
+ if (found.format === 'toml') {
54
+ parsed = toml.parse(raw);
55
+ } else {
56
+ const errors = [];
57
+ parsed = jsonc.parse(raw, errors, { allowTrailingComma: true });
58
+ if (errors.length > 0) {
59
+ throw new DalusConfigError(`Could not parse ${found.path}: ${jsonc.printParseErrorCode(errors[0].error)} near offset ${errors[0].offset}`);
60
+ }
61
+ }
62
+ } catch (err) {
63
+ if (err instanceof DalusConfigError) throw err;
64
+ throw new DalusConfigError(`Could not parse ${found.path}: ${err.message}`);
65
+ }
66
+
67
+ const baseDir = path.dirname(found.path);
68
+ const resolvePathFields = (entry, fields) => {
69
+ const out = { ...entry };
70
+ for (const f of fields) {
71
+ if (typeof out[f] === 'string') out[f] = path.resolve(baseDir, out[f]);
72
+ }
73
+ return out;
74
+ };
75
+
76
+ const resourceKinds = {
77
+ workers: ['main'],
78
+ pages: ['dir'],
79
+ storage: ['dir'],
80
+ database: ['seed'],
81
+ vault: ['rules'],
82
+ };
83
+ const normalized = { apiBase: parsed.apiBase, env: {} };
84
+ for (const kind of Object.keys(resourceKinds)) {
85
+ normalized[kind] = (parsed[kind] || []).map((e) => resolvePathFields(e, resourceKinds[kind]));
86
+ }
87
+ if (parsed.env && typeof parsed.env === 'object') {
88
+ for (const envName of Object.keys(parsed.env)) {
89
+ const envBlock = parsed.env[envName] || {};
90
+ normalized.env[envName] = {};
91
+ for (const kind of Object.keys(resourceKinds)) {
92
+ normalized.env[envName][kind] = (envBlock[kind] || []).map((e) => resolvePathFields(e, resourceKinds[kind]));
93
+ }
94
+ }
95
+ }
96
+ normalized.__configPath = found.path;
97
+ return normalized;
98
+ }
99
+
100
+ /** Applies `--env NAME`, if given: for each resource kind, an env block
101
+ * that defines that kind REPLACES the top-level list for it; a resource
102
+ * kind the env block doesn't mention falls back to the top-level list.
103
+ * This is a deliberately simpler model than Wrangler's deep-merge — easy
104
+ * to explain, easy to predict what a given --env will actually deploy. */
105
+ function resolveEnv(config, envName) {
106
+ if (!envName) return config;
107
+ const envBlock = config.env && config.env[envName];
108
+ if (!envBlock) {
109
+ throw new DalusConfigError(`No [env.${envName}] block in ${config.__configPath}.`);
110
+ }
111
+ const merged = { ...config };
112
+ for (const kind of ['workers', 'pages', 'storage', 'database', 'vault']) {
113
+ if (envBlock[kind] && envBlock[kind].length > 0) merged[kind] = envBlock[kind];
114
+ }
115
+ return merged;
116
+ }
117
+
118
+ function loadCredentials() {
119
+ if (!fs.existsSync(CREDENTIALS_PATH)) return null;
120
+ try {
121
+ return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function saveCredentials(creds) {
128
+ fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
129
+ // Same-user-only permissions — this file holds a real bearer token.
130
+ fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
131
+ }
132
+
133
+ function clearCredentials() {
134
+ if (fs.existsSync(CREDENTIALS_PATH)) fs.unlinkSync(CREDENTIALS_PATH);
135
+ }
136
+
137
+ module.exports = {
138
+ DalusConfigError,
139
+ CONFIG_FILENAMES,
140
+ CREDENTIALS_PATH,
141
+ findConfigFile,
142
+ loadConfig,
143
+ resolveEnv,
144
+ loadCredentials,
145
+ saveCredentials,
146
+ clearCredentials,
147
+ };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+
4
+ /** Deploys one Document DB collection: find-or-create by name, then
5
+ * optionally seeds it from a local JSON file (a single object -> one
6
+ * document, an array -> one document per entry). Seeding only ever
7
+ * ADDS documents — it doesn't diff against or replace what's already in
8
+ * the collection, so re-running against a collection that already has
9
+ * the seed data creates duplicates. Fine for "seed a fresh collection
10
+ * once"; a real sync mode (matching on some key field) would need this
11
+ * project's documents to have a stable id convention to match against,
12
+ * which isn't assumed here. */
13
+ async function forgeDatabase(api, log, entry) {
14
+ log(`Document DB: ${entry.name}`);
15
+
16
+ let collection = await api.findByName('/database/collections', entry.name);
17
+ if (!collection) {
18
+ log(` creating collection...`);
19
+ collection = await api.post('/database/collections', { name: entry.name });
20
+ }
21
+
22
+ if (entry.seed) {
23
+ const raw = JSON.parse(fs.readFileSync(entry.seed, 'utf8'));
24
+ const docs = Array.isArray(raw) ? raw : [raw];
25
+ for (const doc of docs) {
26
+ await api.post(`/database/collections/${collection.id}/documents`, doc);
27
+ }
28
+ log(` seeded ${docs.length} document${docs.length === 1 ? '' : 's'} from ${entry.seed}`);
29
+ }
30
+
31
+ log(` done`);
32
+ }
33
+
34
+ module.exports = { forgeDatabase };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { walkFiles, guessContentType } = require('../fs-utils');
5
+
6
+ /** Deploys one Static Hosting project: find-or-create by name, then PUT
7
+ * every file under `dir`. Uploads are NOT diffed against what's already
8
+ * there — every file in `dir` is re-uploaded every run, matching how
9
+ * `wrangler pages deploy` treats a deploy as a fresh snapshot rather
10
+ * than an incremental sync. Stale files left over from a previous
11
+ * deploy (renamed/removed locally) are not currently cleaned up — worth
12
+ * a `--clean` flag later if that turns out to matter in practice. */
13
+ async function forgePages(api, log, entry) {
14
+ log(`Static Hosting: ${entry.name}`);
15
+ const files = walkFiles(entry.dir);
16
+ if (files.length === 0) throw new Error(`No files found in ${entry.dir}`);
17
+
18
+ let project = await api.findByName('/pages/projects', entry.name);
19
+ if (!project) {
20
+ log(` creating project...`);
21
+ project = await api.post('/pages/projects', { name: entry.name });
22
+ }
23
+
24
+ for (const relPath of files) {
25
+ const fullPath = path.join(entry.dir, relPath);
26
+ const contentType = guessContentType(relPath);
27
+ const body = fs.readFileSync(fullPath);
28
+ await api.putRaw(`/pages/projects/${project.id}/files/${relPath}`, body, contentType);
29
+ log(` uploaded ${relPath} (${contentType})`);
30
+ }
31
+
32
+ log(` done -> ${project.liveUrl || project.fallbackUrl || '(live link not returned by the API)'}`);
33
+ }
34
+
35
+ module.exports = { forgePages };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { walkFiles, guessContentType } = require('../fs-utils');
5
+
6
+ /** Deploys one Object Storage bucket: find-or-create by name, flips
7
+ * public access if configured, then PUTs every file under `dir` as an
8
+ * object keyed by its relative path — same "re-upload everything every
9
+ * run" behavior as the Pages forger, for the same reason (matching a
10
+ * deploy tool's usual "this directory IS the desired state" model). */
11
+ async function forgeStorage(api, log, entry) {
12
+ log(`Object Storage: ${entry.name}`);
13
+
14
+ let bucket = await api.findByName('/storage/buckets', entry.name);
15
+ if (!bucket) {
16
+ log(` creating bucket...`);
17
+ bucket = await api.post('/storage/buckets', { name: entry.name });
18
+ }
19
+
20
+ if (typeof entry.public === 'boolean' && entry.public !== bucket.publicEnabled) {
21
+ bucket = await api.patch(`/storage/buckets/${bucket.id}/public`, { enabled: entry.public });
22
+ log(` public access -> ${entry.public}`);
23
+ }
24
+
25
+ if (entry.dir) {
26
+ const files = walkFiles(entry.dir);
27
+ for (const relPath of files) {
28
+ const fullPath = path.join(entry.dir, relPath);
29
+ const contentType = guessContentType(relPath);
30
+ const body = fs.readFileSync(fullPath);
31
+ const result = await api.putRaw(`/storage/buckets/${bucket.id}/objects/${relPath}`, body, contentType);
32
+ log(` uploaded ${relPath}${result && result.url ? ` -> ${result.url}` : ''}`);
33
+ }
34
+ }
35
+
36
+ log(` done`);
37
+ }
38
+
39
+ module.exports = { forgeStorage };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+
4
+ /** Deploys one Vault: find-or-create by name, then uploads its rules
5
+ * text if configured. Rules are validated server-side (a real parse —
6
+ * see lib/rules.ts in the worker project) before being saved, so a
7
+ * syntax error in the local rules file surfaces here as a clear error,
8
+ * not a silent deploy of broken rules. */
9
+ async function forgeVault(api, log, entry) {
10
+ log(`Vault: ${entry.name}`);
11
+
12
+ let vault = await api.findByName('/vaults', entry.name);
13
+ if (!vault) {
14
+ log(` creating vault...`);
15
+ vault = await api.post('/vaults', { name: entry.name });
16
+ }
17
+
18
+ if (entry.rules) {
19
+ const rules = fs.readFileSync(entry.rules, 'utf8');
20
+ await api.put(`/vaults/${vault.id}/rules`, { rules });
21
+ log(` rules uploaded from ${entry.rules}`);
22
+ }
23
+
24
+ log(` done -> data endpoint at ${vault.dataUrl || `${api.apiBase}/vault/${entry.name}`}`);
25
+ }
26
+
27
+ module.exports = { forgeVault };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const readline = require('readline');
4
+
5
+ function prompt(question) {
6
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
+ return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer); }));
8
+ }
9
+
10
+ /** Resolves { name, type: 'storage'|'database', resource } bindings from
11
+ * config into the { resourceId } shape the API wants, by name-matching
12
+ * against the owner's existing buckets/collections — bindings reference
13
+ * resources by NAME in dalus config (portable across accounts/environments),
14
+ * but the API itself keys them by id. */
15
+ async function resolveBindingResourceId(api, binding) {
16
+ const listPath = binding.type === 'storage' ? '/storage/buckets' : '/database/collections';
17
+ const kindLabel = binding.type === 'storage' ? 'storage bucket' : 'Document DB collection';
18
+ const found = await api.findByName(listPath, binding.resource);
19
+ if (!found) {
20
+ throw new Error(`Binding "${binding.name}" references ${kindLabel} "${binding.resource}", which doesn't exist yet — forge that resource first (dalus forge --storage or --database).`);
21
+ }
22
+ return found.id;
23
+ }
24
+
25
+ async function syncBindings(api, log, serviceId, bindingsConfig) {
26
+ if (!bindingsConfig || bindingsConfig.length === 0) return;
27
+ const existing = await api.get(`/services/${serviceId}/bindings`);
28
+ for (const binding of bindingsConfig) {
29
+ if (existing.some((e) => e.bindingName === binding.name)) {
30
+ log(` binding ${binding.name} already exists, skipping`);
31
+ continue;
32
+ }
33
+ const resourceId = await resolveBindingResourceId(api, binding);
34
+ await api.post(`/services/${serviceId}/bindings`, {
35
+ bindingName: binding.name,
36
+ resourceType: binding.type,
37
+ resourceId,
38
+ canWrite: !!binding.canWrite,
39
+ });
40
+ log(` bound env.${binding.name} -> ${binding.type}:${binding.resource}${binding.canWrite ? ' (read/write)' : ''}`);
41
+ }
42
+ }
43
+
44
+ async function syncSecrets(api, log, serviceId, secretNames, { yes }) {
45
+ if (!secretNames || secretNames.length === 0) return;
46
+ for (const name of secretNames) {
47
+ let value = process.env[name];
48
+ if (!value) {
49
+ if (yes) {
50
+ log(` skipping secret ${name} (no ${name} env var set, and --yes disables prompting)`);
51
+ continue;
52
+ }
53
+ value = await prompt(` Enter value for secret ${name} (or press Enter to skip): `);
54
+ if (!value) { log(` skipped ${name}`); continue; }
55
+ }
56
+ await api.post(`/services/${serviceId}/secrets`, { name, value });
57
+ log(` set secret env.${name}`);
58
+ }
59
+ }
60
+
61
+ /** Deploys one Edge Compute service: find-or-create by name, upload its
62
+ * code, then sync bindings and secrets. Bindings/secrets are synced
63
+ * every run (idempotent — see syncBindings/syncSecrets above); the code
64
+ * upload always overwrites, same as `wrangler deploy` always overwriting
65
+ * a Worker's script. */
66
+ async function forgeWorker(api, log, entry, opts) {
67
+ log(`Edge Compute: ${entry.name}`);
68
+ if (!fs.existsSync(entry.main)) throw new Error(`main file not found: ${entry.main}`);
69
+ const code = fs.readFileSync(entry.main, 'utf8');
70
+
71
+ let svc = await api.findByName('/services', entry.name);
72
+ if (!svc) {
73
+ log(` creating service...`);
74
+ svc = await api.post('/services', { name: entry.name });
75
+ }
76
+ await api.put(`/services/${svc.id}/code`, { code });
77
+ log(` code uploaded (${code.length} bytes)`);
78
+
79
+ await syncBindings(api, log, svc.id, entry.bindings);
80
+ await syncSecrets(api, log, svc.id, entry.secrets, opts);
81
+
82
+ log(` done -> test at ${api.apiBase}/run/${entry.name}`);
83
+ }
84
+
85
+ module.exports = { forgeWorker };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ /** Recursively lists every file under `dir`, returning paths relative to
6
+ * `dir` with forward slashes (matching how the dashboard itself stores
7
+ * Pages/Storage keys) — regardless of the host OS's own separator. */
8
+ function walkFiles(dir) {
9
+ const out = [];
10
+ const walk = (current) => {
11
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
12
+ if (entry.name.startsWith('.')) continue; // .git, .DS_Store, etc.
13
+ const full = path.join(current, entry.name);
14
+ if (entry.isDirectory()) walk(full);
15
+ else out.push(path.relative(dir, full).split(path.sep).join('/'));
16
+ }
17
+ };
18
+ if (!fs.existsSync(dir)) throw new Error(`Directory not found: ${dir}`);
19
+ walk(dir);
20
+ return out;
21
+ }
22
+
23
+ const EXT_TO_CONTENT_TYPE = {
24
+ html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
25
+ mjs: 'application/javascript', json: 'application/json', svg: 'image/svg+xml',
26
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
27
+ webp: 'image/webp', ico: 'image/x-icon', txt: 'text/plain', md: 'text/markdown',
28
+ xml: 'application/xml', pdf: 'application/pdf', woff: 'font/woff', woff2: 'font/woff2',
29
+ };
30
+
31
+ function guessContentType(filePath) {
32
+ const ext = path.extname(filePath).slice(1).toLowerCase();
33
+ return EXT_TO_CONTENT_TYPE[ext] || 'application/octet-stream';
34
+ }
35
+
36
+ module.exports = { walkFiles, guessContentType };