shiply-cli 0.27.2 → 0.29.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/dist/access.js ADDED
@@ -0,0 +1,54 @@
1
+ import { api, resolveBase } from './publish.js';
2
+ const headers = (apiKey) => ({
3
+ 'content-type': 'application/json',
4
+ authorization: `Bearer ${apiKey}`,
5
+ });
6
+ /** Stable machine marker (mirrors SITE_READY / VERIFY) so agents can parse the
7
+ * policy without scraping the human lines. Counts, not values — emails can
8
+ * contain characters that would break k=v tokenizing. */
9
+ function marker(slug, p) {
10
+ return `ACCESS slug=${slug} mode=${p.mode} password=${p.hasPassword ? 'set' : 'none'} emails=${p.allowedEmails.length} domains=${p.allowedDomains.length}`;
11
+ }
12
+ function printPolicy(slug, p) {
13
+ if (p.mode === 'public') {
14
+ console.log(` ${slug} is public — anyone with the URL can view it`);
15
+ }
16
+ else if (p.mode === 'password') {
17
+ console.log(` ${slug} is password-protected${p.hasPassword ? '' : ' (no password set yet — visitors are locked out)'}`);
18
+ }
19
+ else {
20
+ console.log(` ${slug} is invite-only (restricted)`);
21
+ for (const e of p.allowedEmails)
22
+ console.log(` email: ${e}`);
23
+ for (const d of p.allowedDomains)
24
+ console.log(` domain: @${d.replace(/^@/, '')}`);
25
+ if (p.allowedEmails.length === 0 && p.allowedDomains.length === 0) {
26
+ console.log(' (no emails or domains allowed — only the owner can view)');
27
+ }
28
+ }
29
+ console.log(marker(slug, p));
30
+ }
31
+ export async function accessShow(ctx, slug) {
32
+ const base = resolveBase(ctx.base);
33
+ const p = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/access`, {
34
+ headers: headers(ctx.apiKey),
35
+ });
36
+ printPolicy(slug, p);
37
+ }
38
+ export async function accessSet(ctx, slug, input) {
39
+ const base = resolveBase(ctx.base);
40
+ const body = { mode: input.mode };
41
+ if (input.mode === 'password')
42
+ body.password = input.password;
43
+ if (input.mode === 'restricted') {
44
+ body.allowedEmails = input.allowedEmails ?? [];
45
+ body.allowedDomains = input.allowedDomains ?? [];
46
+ }
47
+ const p = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/access`, {
48
+ method: 'PATCH',
49
+ headers: headers(ctx.apiKey),
50
+ body: JSON.stringify(body),
51
+ });
52
+ console.log(`✔ access updated — existing visitor cookies are now invalid`);
53
+ printPolicy(slug, p);
54
+ }
package/dist/data.js CHANGED
@@ -105,6 +105,7 @@ export async function dataExport(ctx, slug, collection, opts) {
105
105
  export async function dataWipeCollection(ctx, slug, collection, opts) {
106
106
  if (!opts.yes) {
107
107
  console.error(` refusing — wipe-collection deletes EVERY record in "${collection}" on ${slug}. Re-run with --yes to confirm.`);
108
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
108
109
  return;
109
110
  }
110
111
  const base = resolveBase(ctx.base);
package/dist/db.js CHANGED
@@ -144,6 +144,7 @@ export async function dbMigrate(ctx, idOrName, dir) {
144
144
  export async function dbDelete(ctx, idOrName, opts) {
145
145
  if (!opts.yes) {
146
146
  console.log(` refusing — \`db delete\` drops the database "${idOrName}" and every row in it. Re-run with --yes to confirm.`);
147
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
147
148
  return;
148
149
  }
149
150
  const id = await resolveId(ctx, idOrName);
@@ -195,6 +196,7 @@ export async function dbBranches(ctx, parentNameOrId) {
195
196
  export async function dbDeleteBranch(ctx, branchIdOrName, opts) {
196
197
  if (!opts.yes) {
197
198
  console.log(` refusing — \`db delete-branch\` drops the branch "${branchIdOrName}" and its Neon endpoint. Re-run with --yes to confirm.`);
199
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
198
200
  return;
199
201
  }
200
202
  const base = resolveBase(ctx.base);
package/dist/domain.js CHANGED
@@ -143,6 +143,36 @@ export async function domainPrimary(ctx, domain, hostname) {
143
143
  console.log(` ${s.hostname}`);
144
144
  }
145
145
  }
146
+ export async function domainRm(ctx, domain, opts = {}) {
147
+ if (!opts.yes) {
148
+ throw new Error(`refusing to remove ${domain} without --yes (subdomains mapped to it stop resolving)`);
149
+ }
150
+ const base = resolveBase(ctx.base);
151
+ const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}`, {
152
+ method: 'DELETE',
153
+ headers: headers(ctx.apiKey),
154
+ });
155
+ console.log(`✔ removed ${r.removed}`);
156
+ }
157
+ /** Re-poll CF cert status + live TLS/HTTPS probe for all subdomains of a
158
+ * registered custom domain. Run this after `domain connect` / `domain sub add`
159
+ * until every subdomain reports ready=true. */
160
+ export async function domainCheck(ctx, domain) {
161
+ const base = resolveBase(ctx.base);
162
+ const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}/check`, {
163
+ method: 'POST',
164
+ headers: headers(ctx.apiKey),
165
+ body: '{}',
166
+ });
167
+ if (!r.subdomains?.length) {
168
+ console.log(`No subdomains mapped under ${domain} yet. shiply domain sub add <host> --site <slug>`);
169
+ return;
170
+ }
171
+ for (const s of r.subdomains) {
172
+ console.log(` ${s.hostname} ${s.ready ? '✔ ready' : '✖ not ready'}`);
173
+ console.log(`DOMAIN_CHECK hostname=${s.hostname} ready=${s.ready}`);
174
+ }
175
+ }
146
176
  export async function domainSync(ctx, domain) {
147
177
  const base = resolveBase(ctx.base);
148
178
  const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}/sync-dns`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
package/dist/drive.js CHANGED
@@ -30,8 +30,12 @@ export async function drivePut(ctx, file, asPath) {
30
30
  const stage = await api(`${base}/api/v1/drives/${id}/files/uploads`, { method: 'POST', headers: headers(ctx.apiKey), body: JSON.stringify({ files: [meta] }) });
31
31
  for (const u of stage.uploads) {
32
32
  const res = await fetch(u.url, { method: 'PUT', body: new Uint8Array(body) });
33
- if (!res.ok)
34
- throw new Error(`upload failed for ${u.path}`);
33
+ if (!res.ok) {
34
+ if (res.status === 401 || res.status === 403) {
35
+ throw new Error(`upload failed for ${u.path}: HTTP ${res.status} — the upload URL is not authorized. Re-run \`shiply drive put\` to mint a fresh one.`);
36
+ }
37
+ throw new Error(`upload failed for ${u.path}: HTTP ${res.status} — the object store rejected the file. Re-run \`shiply drive put\`; if it keeps failing, check the file size/name.`);
38
+ }
35
39
  }
36
40
  await api(`${base}/api/v1/drives/${id}/files/finalize`, {
37
41
  method: 'POST',
@@ -45,8 +49,15 @@ export async function driveGet(ctx, path, out) {
45
49
  const id = await defaultDrive(ctx);
46
50
  const { url } = await api(`${base}/api/v1/drives/${id}/files/${path.split('/').map(encodeURIComponent).join('/')}`, { headers: headers(ctx.apiKey) });
47
51
  const res = await fetch(url);
48
- if (!res.ok)
49
- throw new Error(`download failed (${res.status})`);
52
+ if (!res.ok) {
53
+ if (res.status === 404) {
54
+ throw new Error(`download failed: HTTP 404 — no file at "${path}" in this drive. Check the path with \`shiply drive ls\`.`);
55
+ }
56
+ if (res.status === 401 || res.status === 403) {
57
+ throw new Error(`download failed: HTTP ${res.status} — the download URL is not authorized. Re-run \`shiply drive get\` to mint a fresh one.`);
58
+ }
59
+ throw new Error(`download failed: HTTP ${res.status} for "${path}" — re-run \`shiply drive get\`; if it keeps failing, the object may be corrupted.`);
60
+ }
50
61
  const buf = Buffer.from(await res.arrayBuffer());
51
62
  if (out) {
52
63
  await writeFile(out, buf);
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { detectIsr } from './isr.js';
15
15
  import { runClaimVerify } from './claim.js';
16
16
  import { dbAttach, dbBranch, dbBranches, dbCreate, dbDelete, dbDeleteBranch, dbLs, dbMerge, dbMigrate, dbSql, } from './db.js';
17
17
  import { driveGet, driveLs, drivePublish, drivePut, driveRm } from './drive.js';
18
- import { domainAdd, domainConnect, domainLs, domainPrimary, domainSubAdd, domainSync } from './domain.js';
18
+ import { domainAdd, domainCheck, domainConnect, domainLs, domainPrimary, domainRm, domainSubAdd, domainSync } from './domain.js';
19
19
  import { runProject } from './projects.js';
20
20
  import { runClient, parseClientArg } from './clients.js';
21
21
  import { runListing } from './listings.js';
@@ -33,8 +33,10 @@ import { readState, writeState } from './state.js';
33
33
  import { readPreview, writePreview } from './previews.js';
34
34
  import { checkReadiness, targetToHostname } from './status.js';
35
35
  import { runStatus } from './status-cmd.js';
36
+ import { accessSet, accessShow } from './access.js';
36
37
  import { sitesLs, sitesPromote, sitesRm, sitesRollback } from './sites.js';
37
38
  import { verifySite } from './verify.js';
39
+ import { variableAttach, variableDetach, variableLs, variableRm, variableSet } from './variable.js';
38
40
  // Resolve the package.json beside the compiled dist/ so `shiply --version`
39
41
  // can print the running CLI version (works in dev tsc output + npm install).
40
42
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -62,6 +64,10 @@ Usage:
62
64
  shiply status <slug-or-domain> [--wait] SSL + readiness check (agent-friendly output)
63
65
  (no arg → account/plan status, see below)
64
66
  shiply duplicate <slug> Server-side copy of an owned site → new URL
67
+ shiply ls List sites in your account
68
+ shiply rm <slug> --yes Permanently delete an owned site
69
+ shiply rollback <slug> [versionId] Re-point a site to a prior finalized deploy
70
+ (omit versionId to list deploys and pick one)
65
71
  shiply promote <preview-slug> --to <dest-slug>
66
72
  Copy the exact previewed bytes into a production site (no rebuild)
67
73
  shiply detect [dir] Diagnose: print framework + dir that would upload
@@ -75,12 +81,26 @@ Usage:
75
81
  shiply db branches <db> List branches of a Neon database
76
82
  shiply db delete-branch <branch-or-id> --yes Delete a Neon branch
77
83
  shiply db merge <branch> No-op alias — prints the pg_dump migration tip
84
+ shiply access <slug> Show who can view the site (public/password/restricted)
85
+ shiply access <slug> --public Open the site to everyone
86
+ shiply access <slug> --password <pw> Password-protect the site (paid plan)
87
+ shiply access <slug> --restricted --email a@b.com --domain example.com
88
+ Invite-only: only listed emails / email domains
89
+ (--email/--domain repeatable; replaces the previous lists)
78
90
  shiply domain ls List custom domains on your account
79
91
  shiply domain add <domain> Register a custom domain
80
92
  shiply domain connect <domain> One-click OAuth DNS setup (Cloudflare/Domain Connect)
81
93
  shiply domain sub add <host> --site <slug> Map a subdomain (e.g. app.example.com) to a slug
82
94
  shiply domain sync <domain> Re-sync DNS records for a connected domain
83
95
  shiply domain primary <domain> <hostname> Mark hostname as primary; siblings 301-redirect (SEO)
96
+ shiply domain check <domain> Re-poll cert/DNS/HTTPS readiness for every subdomain
97
+ shiply domain rm <domain> --yes Remove a custom domain (its subdomains stop resolving)
98
+ shiply variable <ls|set|rm|attach|detach> Account-level encrypted variables (e.g. API keys for sites)
99
+ shiply variable set <NAME> <value> Save/update a variable (or: --stdin to read the value)
100
+ shiply variable ls [--reveal] List variables (masked by default)
101
+ shiply variable rm <NAME> --yes Delete a variable
102
+ shiply variable attach <NAME> --site <slug> Expose a saved variable to one site's Worker env
103
+ shiply variable detach <NAME> --site <slug> Stop exposing a variable to a site's Worker env
84
104
  shiply project <ls|create|get|archive|restore>
85
105
  Customer-intake projects + AI brief generator
86
106
  shiply project create <label> [--customer-email <e>] [--customer-name <n>]
@@ -213,7 +233,7 @@ async function main() {
213
233
  // parseArgs sees it. (A prior lookahead that cancelled the flag when a
214
234
  // positional followed made `shiply publish --json ./dist` swallow the dir as
215
235
  // the --json value, leaving no <dir>.)
216
- const BARE_JSON_CMDS = new Set(['status', 'publish', 'update', 'logs']);
236
+ const BARE_JSON_CMDS = new Set(['status', 'publish', 'update', 'logs', 'variable']);
217
237
  const bareJsonFlag = BARE_JSON_CMDS.has(rawArgv[0]) && rawArgv.includes('--json');
218
238
  if (bareJsonFlag) {
219
239
  const idx = rawArgv.indexOf('--json');
@@ -231,7 +251,13 @@ async function main() {
231
251
  key: { type: 'string' },
232
252
  anonymous: { type: 'boolean' },
233
253
  base: { type: 'string' },
234
- email: { type: 'string' },
254
+ // `--email` is repeatable for `access --restricted`; `login --email` uses
255
+ // the last value. `--domain` is the access counterpart for whole domains.
256
+ email: { type: 'string', multiple: true },
257
+ domain: { type: 'string', multiple: true },
258
+ public: { type: 'boolean' },
259
+ password: { type: 'string' },
260
+ restricted: { type: 'boolean' },
235
261
  name: { type: 'string' },
236
262
  as: { type: 'string' },
237
263
  wait: { type: 'boolean' },
@@ -250,6 +276,8 @@ async function main() {
250
276
  ts: { type: 'boolean' },
251
277
  'preview-branch': { type: 'string' },
252
278
  yes: { type: 'boolean' },
279
+ reveal: { type: 'boolean' },
280
+ stdin: { type: 'boolean' },
253
281
  'no-confetti': { type: 'boolean' },
254
282
  json: { type: 'string' },
255
283
  limit: { type: 'string' },
@@ -617,6 +645,47 @@ async function main() {
617
645
  process.exitCode = ready ? 0 : 1;
618
646
  return;
619
647
  }
648
+ case 'access': {
649
+ const usage = 'usage: shiply access <slug> [--public | --password <pw> | --restricted --email a@b.com --domain example.com]';
650
+ if (!dir)
651
+ throw new Error(usage);
652
+ const apiKey = values.key ?? (await loadApiKey());
653
+ if (!apiKey)
654
+ throw new Error('access needs an API key — run `shiply login` first');
655
+ const actx = { base: values.base, apiKey };
656
+ const modes = [
657
+ values.public ? 'public' : null,
658
+ values.password !== undefined ? 'password' : null,
659
+ values.restricted ? 'restricted' : null,
660
+ ].filter(Boolean);
661
+ if (modes.length === 0) {
662
+ // No mode flag → read-only: print the current policy.
663
+ await accessShow(actx, dir);
664
+ return;
665
+ }
666
+ if (modes.length > 1)
667
+ throw new Error(`pick ONE mode — ${usage}`);
668
+ const mode = modes[0];
669
+ // --email/--domain are repeatable and accept comma-separated lists.
670
+ const split = (vals) => (vals ?? []).flatMap((v) => v.split(',')).map((s) => s.trim()).filter(Boolean);
671
+ const allowedEmails = split(values.email);
672
+ const allowedDomains = split(values.domain);
673
+ if (mode === 'password' && !values.password?.trim()) {
674
+ throw new Error('--password needs a value, e.g. shiply access <slug> --password "s3cret-phrase"');
675
+ }
676
+ if (mode !== 'restricted' && (allowedEmails.length > 0 || allowedDomains.length > 0)) {
677
+ throw new Error('--email/--domain only apply with --restricted');
678
+ }
679
+ if (mode === 'restricted' && allowedEmails.length === 0 && allowedDomains.length === 0) {
680
+ throw new Error('--restricted needs at least one --email or --domain (otherwise only you could view the site)');
681
+ }
682
+ await accessSet(actx, dir, {
683
+ mode,
684
+ ...(mode === 'password' ? { password: values.password.trim() } : {}),
685
+ ...(mode === 'restricted' ? { allowedEmails, allowedDomains } : {}),
686
+ });
687
+ return;
688
+ }
620
689
  case 'promote': {
621
690
  const dest = values.to;
622
691
  if (!dir || !dest)
@@ -778,8 +847,18 @@ async function main() {
778
847
  throw new Error('usage: shiply domain primary <domain> <hostname>');
779
848
  await domainPrimary(dctx, arg, arg2);
780
849
  return;
850
+ case 'rm':
851
+ if (!arg)
852
+ throw new Error('usage: shiply domain rm <domain> --yes');
853
+ await domainRm(dctx, arg, { yes: Boolean(values.yes) });
854
+ return;
855
+ case 'check':
856
+ if (!arg)
857
+ throw new Error('usage: shiply domain check <domain>');
858
+ await domainCheck(dctx, arg);
859
+ return;
781
860
  default:
782
- throw new Error('usage: shiply domain <ls|add|connect|sub|sync|primary>');
861
+ throw new Error('usage: shiply domain <ls|add|connect|sub|sync|primary|rm|check>');
783
862
  }
784
863
  }
785
864
  case 'db': {
@@ -850,6 +929,55 @@ async function main() {
850
929
  throw new Error('usage: shiply db <create|ls|sql|migrate|delete|attach|branch|branches|delete-branch|merge>');
851
930
  }
852
931
  }
932
+ case 'variable': {
933
+ const apiKey = values.key ?? (await loadApiKey());
934
+ if (!apiKey)
935
+ throw new Error('variable commands need an API key — run `shiply login` first');
936
+ const vctx = { base: values.base, apiKey };
937
+ const jsonOut = bareJsonFlag;
938
+ const sub = dir; // positionals[1]
939
+ const arg = positionals[2]; // name
940
+ const arg2 = positionals[3]; // value (set) — unused for others
941
+ switch (sub) {
942
+ case 'ls':
943
+ await variableLs(vctx, { reveal: Boolean(values.reveal), json: jsonOut });
944
+ return;
945
+ case 'set': {
946
+ if (!arg)
947
+ throw new Error("usage: shiply variable set <NAME> <value> (or: shiply variable set <NAME> --stdin)");
948
+ let value = arg2;
949
+ if (values.stdin) {
950
+ const { readFileSync } = await import('node:fs');
951
+ value = readFileSync(0, 'utf8').replace(/\n$/, '');
952
+ }
953
+ if (value === undefined)
954
+ throw new Error("usage: shiply variable set <NAME> <value> (or: shiply variable set <NAME> --stdin)");
955
+ await variableSet(vctx, arg, value, { json: jsonOut });
956
+ return;
957
+ }
958
+ case 'rm':
959
+ if (!arg)
960
+ throw new Error('usage: shiply variable rm <NAME> --yes');
961
+ await variableRm(vctx, arg, { yes: Boolean(values.yes), json: jsonOut });
962
+ return;
963
+ case 'attach':
964
+ if (!arg)
965
+ throw new Error('usage: shiply variable attach <NAME> --site <slug>');
966
+ if (!values.site)
967
+ throw new Error('--site <slug> is required for `shiply variable attach`');
968
+ await variableAttach(vctx, arg, values.site, { json: jsonOut });
969
+ return;
970
+ case 'detach':
971
+ if (!arg)
972
+ throw new Error('usage: shiply variable detach <NAME> --site <slug>');
973
+ if (!values.site)
974
+ throw new Error('--site <slug> is required for `shiply variable detach`');
975
+ await variableDetach(vctx, arg, values.site, { json: jsonOut });
976
+ return;
977
+ default:
978
+ throw new Error('usage: shiply variable <ls|set|rm|attach|detach>');
979
+ }
980
+ }
853
981
  case 'project': {
854
982
  await runProject(positionals.slice(1));
855
983
  break;
@@ -993,14 +1121,17 @@ async function main() {
993
1121
  break;
994
1122
  }
995
1123
  case 'claim': {
1124
+ // Normalized to exit 1 (was 2) — every other usage error in this CLI
1125
+ // exits 1, and automation that checks `exit !== 0` shouldn't need a
1126
+ // special case for this one command.
996
1127
  if (dir !== 'verify') {
997
1128
  console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
998
- process.exit(2);
1129
+ process.exit(1);
999
1130
  }
1000
1131
  const code = positionals[2];
1001
1132
  if (!code) {
1002
1133
  console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
1003
- process.exit(2);
1134
+ process.exit(1);
1004
1135
  }
1005
1136
  const r = await runClaimVerify({ code });
1006
1137
  console.log(r.message);
@@ -1082,7 +1213,7 @@ async function main() {
1082
1213
  // Default path: device flow. One click in the browser, no email round-trip.
1083
1214
  // The --email flag opts into the legacy code-by-email path (useful for
1084
1215
  // scripted/non-interactive runs that can't open a browser).
1085
- if (!values.email) {
1216
+ if (!values.email?.length) {
1086
1217
  const result = await loginViaDeviceFlow(base, values.name);
1087
1218
  if (!result.ok) {
1088
1219
  console.error(`✗ login failed: ${result.reason}`);
@@ -1097,7 +1228,7 @@ async function main() {
1097
1228
  }
1098
1229
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1099
1230
  try {
1100
- const email = values.email;
1231
+ const email = values.email[values.email.length - 1];
1101
1232
  await api(`${base}/api/auth/agent/request-code`, {
1102
1233
  method: 'POST',
1103
1234
  headers: { 'content-type': 'application/json' },
@@ -1131,6 +1262,18 @@ main().catch((e) => {
1131
1262
  console.error(` upgrade: https://shiply.now/dashboard/plan`);
1132
1263
  process.exit(1);
1133
1264
  }
1265
+ // Auth refusals (server returns 401/403): a bare "sign in" tells an agent
1266
+ // nothing about HOW, and a revoked key vs a plan/ownership limit need
1267
+ // different next actions — branch on the code instead of printing raw text.
1268
+ if (e instanceof ApiError && e.code === 'unauthorized') {
1269
+ console.error(`✖ API key missing, invalid, or revoked — run \`shiply login\` for a fresh key (or check ~/.shiply/credentials)`);
1270
+ process.exit(1);
1271
+ }
1272
+ if (e instanceof ApiError && e.code === 'forbidden') {
1273
+ console.error(`✖ ${e.message}`);
1274
+ console.error(` if this is a plan limit: https://shiply.now/dashboard/plan`);
1275
+ process.exit(1);
1276
+ }
1134
1277
  const msg = e instanceof Error ? e.message : String(e);
1135
1278
  console.error(`✖ ${msg}`);
1136
1279
  process.exit(1);
package/dist/login.js CHANGED
@@ -36,9 +36,15 @@ export async function loginViaDeviceFlow(base, agentNameOverride) {
36
36
  }
37
37
  catch (e) {
38
38
  // Transient network blip — don't kill the whole flow, the user has time.
39
- if (e instanceof ApiError && e.status >= 500)
40
- continue;
41
- throw e;
39
+ // Covers both server-side 5xx (ApiError) AND transport failures (DNS
40
+ // hiccup, connection reset, timeout) that never make it to an ApiError —
41
+ // those used to abort the whole device-flow wait on one dropped packet.
42
+ if (e instanceof ApiError) {
43
+ if (e.status >= 500)
44
+ continue;
45
+ throw e;
46
+ }
47
+ continue;
42
48
  }
43
49
  if (poll.status === 'approved' && poll.api_key) {
44
50
  return { ok: true, apiKey: poll.api_key, slug: poll.slug_claimed ?? null };
package/dist/publish.js CHANGED
@@ -251,7 +251,12 @@ async function uploadAll(dir, targets) {
251
251
  `(they're short-lived). Re-run \`shiply publish\` to mint fresh ones — ` +
252
252
  `unchanged files are hash-skipped, so the retry is cheap.`);
253
253
  }
254
- throw new Error(`upload failed for ${t.path}: HTTP ${res.status}`);
254
+ if (res.status === 401 || res.status === 403) {
255
+ throw new Error(`upload failed for ${t.path}: HTTP ${res.status} — the upload URL is not authorized. ` +
256
+ `Re-run \`shiply publish\` to mint fresh presigned URLs (unchanged files are hash-skipped, so the retry is cheap).`);
257
+ }
258
+ throw new Error(`upload failed for ${t.path}: HTTP ${res.status} — the object store rejected the file. ` +
259
+ `Re-run \`shiply publish\`; if it keeps failing on this file, check its size/name for anything unusual.`);
255
260
  }
256
261
  }
257
262
  };
@@ -104,6 +104,7 @@ async function verifyAction(ctx, id, flags) {
104
104
  async function removeAction(ctx, id, flags) {
105
105
  if (!flags.yes) {
106
106
  console.log(` refusing — \`sending-domain rm\` deletes the domain and forces existing demand tests to fall back to the shared shiply sender. Re-run with --yes to confirm.`);
107
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
107
108
  return;
108
109
  }
109
110
  const base = resolveBase(ctx.base);
package/dist/sites.js CHANGED
@@ -20,6 +20,7 @@ export async function sitesLs(ctx) {
20
20
  export async function sitesRm(ctx, slug, opts) {
21
21
  if (!opts.yes) {
22
22
  console.error(` refusing — \`shiply rm\` PERMANENTLY deletes ${slug} and its files. Re-run with --yes to confirm.`);
23
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
23
24
  return;
24
25
  }
25
26
  const base = resolveBase(ctx.base);
@@ -0,0 +1,80 @@
1
+ import { api, resolveBase } from './publish.js';
2
+ const headers = (apiKey) => ({
3
+ 'content-type': 'application/json',
4
+ authorization: `Bearer ${apiKey}`,
5
+ });
6
+ export async function variableLs(ctx, opts = {}) {
7
+ const base = resolveBase(ctx.base);
8
+ const reveal = Boolean(opts.reveal);
9
+ const r = await api(`${base}/api/v1/variables${reveal ? '?reveal=1' : ''}`, { headers: headers(ctx.apiKey) });
10
+ if (opts.json) {
11
+ process.stdout.write(`${JSON.stringify(r.variables)}\n`);
12
+ return;
13
+ }
14
+ if (r.variables.length === 0) {
15
+ console.log('No variables saved. shiply variable set <NAME> <value>');
16
+ return;
17
+ }
18
+ for (const v of r.variables) {
19
+ console.log(` ${v.name} ${reveal ? (v.value ?? '') : (v.masked ?? '')}`);
20
+ console.log(`VARIABLE name=${v.name} value=${reveal ? (v.value ?? '') : (v.masked ?? '')}`);
21
+ }
22
+ }
23
+ export async function variableSet(ctx, name, value, opts = {}) {
24
+ const base = resolveBase(ctx.base);
25
+ await api(`${base}/api/v1/variables`, {
26
+ method: 'PUT',
27
+ headers: headers(ctx.apiKey),
28
+ body: JSON.stringify({ name, value }),
29
+ });
30
+ const upper = name.trim().toUpperCase();
31
+ if (opts.json) {
32
+ process.stdout.write(`${JSON.stringify({ name: upper, set: true })}\n`);
33
+ return;
34
+ }
35
+ console.log(`✔ ${upper} saved`);
36
+ console.log(' not exposed to any site yet — attach it: shiply variable attach ' + upper + ' --site <slug>');
37
+ }
38
+ export async function variableRm(ctx, name, opts = {}) {
39
+ if (!opts.yes) {
40
+ throw new Error(`refusing to delete ${name} without --yes (this cannot be undone)`);
41
+ }
42
+ const base = resolveBase(ctx.base);
43
+ await api(`${base}/api/v1/variables/${encodeURIComponent(name)}`, {
44
+ method: 'DELETE',
45
+ headers: headers(ctx.apiKey),
46
+ });
47
+ if (opts.json) {
48
+ process.stdout.write(`${JSON.stringify({ name: name.trim().toUpperCase(), removed: true })}\n`);
49
+ return;
50
+ }
51
+ console.log(`✔ ${name.trim().toUpperCase()} removed`);
52
+ }
53
+ export async function variableAttach(ctx, name, slug, opts = {}) {
54
+ const base = resolveBase(ctx.base);
55
+ await api(`${base}/api/v1/variables/${encodeURIComponent(name)}/attach`, {
56
+ method: 'POST',
57
+ headers: headers(ctx.apiKey),
58
+ body: JSON.stringify({ slug }),
59
+ });
60
+ const upper = name.trim().toUpperCase();
61
+ if (opts.json) {
62
+ process.stdout.write(`${JSON.stringify({ name: upper, slug, attached: true })}\n`);
63
+ return;
64
+ }
65
+ console.log(`✔ ${upper} attached to ${slug} — takes effect on the site's next function deploy`);
66
+ }
67
+ export async function variableDetach(ctx, name, slug, opts = {}) {
68
+ const base = resolveBase(ctx.base);
69
+ await api(`${base}/api/v1/variables/${encodeURIComponent(name)}/detach`, {
70
+ method: 'POST',
71
+ headers: headers(ctx.apiKey),
72
+ body: JSON.stringify({ slug }),
73
+ });
74
+ const upper = name.trim().toUpperCase();
75
+ if (opts.json) {
76
+ process.stdout.write(`${JSON.stringify({ name: upper, slug, detached: true })}\n`);
77
+ return;
78
+ }
79
+ console.log(`✔ ${upper} detached from ${slug} — takes effect on the site's next function deploy`);
80
+ }
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
- {
2
- "name": "shiply-cli",
3
- "version": "0.27.2",
4
- "description": "Publish static sites to shiply.now from the command line \u00e2\u20ac\u201d instant web hosting for agents.",
5
- "license": "MIT",
6
- "type": "module",
7
- "bin": {
8
- "shiply": "dist/index.js"
9
- },
10
- "files": [
11
- "dist",
12
- "skill",
13
- "README.md"
14
- ],
15
- "scripts": {
16
- "build": "tsc -p tsconfig.build.json",
17
- "prepublishOnly": "pnpm build",
18
- "test": "vitest run",
19
- "typecheck": "tsc --noEmit"
20
- },
21
- "engines": {
22
- "node": ">=18"
23
- },
24
- "keywords": [
25
- "shiply",
26
- "hosting",
27
- "static",
28
- "deploy",
29
- "agents",
30
- "publish"
31
- ],
32
- "homepage": "https://shiply.now",
33
- "devDependencies": {
34
- "@types/node": "^22.15.0",
35
- "typescript": "^5.8.0",
36
- "vitest": "^3.1.0"
37
- },
38
- "dependencies": {
39
- "esbuild": "^0.25.12",
40
- "smol-toml": "^1.7.0"
41
- }
42
- }
1
+ {
2
+ "name": "shiply-cli",
3
+ "version": "0.29.0",
4
+ "description": "Publish static sites to shiply.now from the command line — instant web hosting for agents.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "shiply": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "skill",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "prepublishOnly": "pnpm build",
18
+ "test": "vitest run",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "keywords": [
25
+ "shiply",
26
+ "hosting",
27
+ "static",
28
+ "deploy",
29
+ "agents",
30
+ "publish"
31
+ ],
32
+ "homepage": "https://shiply.now",
33
+ "devDependencies": {
34
+ "@types/node": "^22.15.0",
35
+ "typescript": "^5.8.0",
36
+ "vitest": "^3.1.0"
37
+ },
38
+ "dependencies": {
39
+ "esbuild": "^0.25.12",
40
+ "smol-toml": "^1.7.0"
41
+ }
42
+ }
@@ -6,6 +6,16 @@ entry here, that capability may be missing from your copy — re-install with
6
6
  (This is the agent-facing capability log; the CLI's own release notes live in
7
7
  the npm package changelog.)
8
8
 
9
+ ## 2026-07-14
10
+
11
+ - **New** — `shiply access <slug>` manages site privacy without raw REST:
12
+ no flags prints the current policy (with a stable `ACCESS slug=… mode=…`
13
+ marker line), `--password <pw>` password-protects, `--restricted
14
+ --email a@b.com --domain example.com` makes it invite-only (flags
15
+ repeatable; each set replaces the previous lists), `--public` opens it
16
+ back up. Password/restricted modes need a paid plan. Details in
17
+ references/site-features.md.
18
+
9
19
  ## 2026-07-08
10
20
 
11
21
  - **Fix** — re-publishing an unchanged large SSR site (e.g. a 171-file