@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 +158 -0
- package/bin/dalus.js +60 -0
- package/package.json +18 -0
- package/src/api-client.js +67 -0
- package/src/commands/forge.js +104 -0
- package/src/commands/init.js +53 -0
- package/src/commands/login.js +54 -0
- package/src/config.js +147 -0
- package/src/forgers/database.js +34 -0
- package/src/forgers/pages.js +35 -0
- package/src/forgers/storage.js +39 -0
- package/src/forgers/vault.js +27 -0
- package/src/forgers/workers.js +85 -0
- package/src/fs-utils.js +36 -0
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 };
|
package/src/fs-utils.js
ADDED
|
@@ -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 };
|